Merged in release/2026-04-03 (pull request #3141)

Release/2026 04 03
This commit is contained in:
Dave Richer
2026-03-19 18:47:24 +00:00
53 changed files with 6614 additions and 2343 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,33 @@
This will connect to your dockers local stack session and render the email in HTML.
This app connects to your Docker LocalStack SES endpoint and gives you a local inbox-style viewer
for generated emails.
```shell
npm start
```
Or:
```shell
node index.js
```
http://localhost:3334
Open: http://localhost:3334
Features:
- Manual refresh
- Live refresh with adjustable polling interval
- Search across subject, addresses, preview text, and attachment names
- Expand/collapse all messages
- Rendered HTML, plain-text, and raw MIME views
- Copy raw MIME source
- New-message highlighting plus fetch timing and parse-error stats
Optional environment variables:
```shell
PORT=3334
SES_VIEWER_ENDPOINT=http://localhost:4566/_aws/ses
SES_VIEWER_REFRESH_MS=10000
SES_VIEWER_FETCH_TIMEOUT_MS=5000
```

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node index.js",
"check": "node --check index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"description": "LocalStack SES email viewer for inspecting local outbound mail",
"dependencies": {
"express": "^5.1.0",
"mailparser": "^3.7.4",

940
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.35.3",
"@amplitude/analytics-browser": "^2.36.6",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.6",
"@dnd-kit/core": "^6.3.1",
@@ -16,45 +16,45 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
"@firebase/app": "^0.14.8",
"@firebase/auth": "^1.12.0",
"@firebase/firestore": "^4.11.0",
"@firebase/messaging": "^0.12.22",
"@fingerprintjs/fingerprintjs": "^5.1.0",
"@firebase/analytics": "^0.10.20",
"@firebase/app": "^0.14.9",
"@firebase/auth": "^1.12.1",
"@firebase/firestore": "^4.12.0",
"@firebase/messaging": "^0.12.24",
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.2.2",
"@sentry/react": "^10.40.0",
"@sentry/cli": "^3.3.3",
"@sentry/react": "^10.43.0",
"@sentry/vite-plugin": "^4.9.1",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.63",
"antd": "^6.3.1",
"antd": "^6.3.3",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.5",
"axios": "^1.13.6",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
"dayjs": "^1.11.20",
"dayjs-business-days2": "^1.3.2",
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.13.0",
"graphql": "^16.13.1",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.13",
"i18next": "^25.8.18",
"i18next-browser-languagedetector": "^8.2.1",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.38",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"libphonenumber-js": "^1.12.40",
"lightningcss": "^1.32.0",
"logrocket": "^12.1.0",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.71",
"posthog-js": "^1.355.0",
"posthog-js": "^1.360.2",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
@@ -65,8 +65,8 @@
"react-dom": "^19.2.4",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-i18next": "^16.5.8",
"react-icons": "^5.6.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
"react-number-format": "^5.4.3",
@@ -76,8 +76,8 @@
"react-resizable": "^3.1.3",
"react-router-dom": "^7.13.1",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1",
"recharts": "^3.7.0",
"react-virtuoso": "^4.18.3",
"recharts": "^3.8.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -85,7 +85,7 @@
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"rxjs": "^7.8.2",
"sass": "^1.97.3",
"sass": "^1.98.0",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.11",
"vite-plugin-ejs": "^1.7.0",
@@ -140,7 +140,7 @@
"@ant-design/icons": "^6.1.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/preset-react": "^7.28.5",
"@dotenvx/dotenvx": "^1.52.0",
"@dotenvx/dotenvx": "^1.55.1",
"@emotion/babel-plugin": "^11.13.5",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.39.2",
@@ -156,9 +156,9 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.3.0",
"globals": "^17.4.0",
"jsdom": "^28.1.0",
"memfs": "^4.56.10",
"memfs": "^4.56.11",
"os-browserify": "^0.3.0",
"playwright": "^1.58.2",
"react-error-overlay": "^6.1.0",
@@ -170,7 +170,7 @@
"vite-plugin-node-polyfills": "^0.25.0",
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-style-import": "^2.0.0",
"vitest": "^4.0.18",
"vitest": "^4.1.0",
"workbox-window": "^7.4.0"
}
}

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

@@ -10,8 +10,13 @@ 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) return null;
if (value === null || value === undefined || value === "") return null;
switch (type) {
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);
@@ -20,8 +25,15 @@ const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
case "text":
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
case "currency":
return <div>{Dinero({ amount: Math.round(value * 100) }).toFormat()}</div>;
case "currency": {
const numericValue = toFiniteNumber(value);
if (numericValue === null) {
return null;
}
return <div>{Dinero({ amount: Math.round(numericValue * 100) }).toFormat()}</div>;
}
default:
return <div style={{ wordWrap: "break-word", overflowWrap: "break-word" }}>{value}</div>;
}

View File

@@ -1,7 +1,7 @@
import { DownOutlined, UpOutlined } from "@ant-design/icons";
import { Space } from "antd";
export default function FormListMoveArrows({ move, index, total }) {
export default function FormListMoveArrows({ move, index, total, orientation = "vertical" }) {
const upDisabled = index === 0;
const downDisabled = index === total - 1;
@@ -14,7 +14,7 @@ export default function FormListMoveArrows({ move, index, total }) {
};
return (
<Space orientation="vertical">
<Space orientation={orientation}>
<UpOutlined disabled={upDisabled} onClick={handleUp} />
<DownOutlined disabled={downDisabled} onClick={handleDown} />
</Space>

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

@@ -21,6 +21,8 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician
});
const getRequestErrorMessage = (error) => error?.response?.data?.error || error?.message || "";
export function PayrollLaborAllocationsTable({
jobId,
joblines,
@@ -43,16 +45,23 @@ export function PayrollLaborAllocationsTable({
});
const notification = useNotification();
useEffect(() => {
async function CalculateTotals() {
const loadTotals = async () => {
try {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
} catch (error) {
setTotals([]);
notification.error({
title: getRequestErrorMessage(error)
});
}
};
useEffect(() => {
if (!!joblines && !!timetickets && !!bodyshop) {
CalculateTotals();
loadTotals();
}
if (!jobId) setTotals([]);
}, [joblines, timetickets, bodyshop, adjustments, jobId]);
@@ -210,28 +219,36 @@ export function PayrollLaborAllocationsTable({
<Button
disabled={!hasTimeTicketAccess}
onClick={async () => {
const response = await axios.post("/payroll/payall", {
jobid: jobId
});
try {
const response = await axios.post("/payroll/payall", {
jobid: jobId
});
if (response.status === 200) {
if (response.data.success !== false) {
notification.success({
title: t("timetickets.successes.payall")
});
if (response.status === 200) {
if (response.data.success !== false) {
notification.success({
title: t("timetickets.successes.payall")
});
} else {
notification.error({
title: t("timetickets.errors.payall", {
error: response.data.error
})
});
}
if (refetch) refetch();
} else {
notification.error({
title: t("timetickets.errors.payall", {
error: response.data.error
error: JSON.stringify("")
})
});
}
if (refetch) refetch();
} else {
} catch (error) {
notification.error({
title: t("timetickets.errors.payall", {
error: JSON.stringify("")
error: getRequestErrorMessage(error)
})
});
}
@@ -241,10 +258,7 @@ export function PayrollLaborAllocationsTable({
</Button>
<Button
onClick={async () => {
const { data } = await axios.post("/payroll/calculatelabor", {
jobid: jobId
});
setTotals(data);
await loadTotals();
refetch();
}}
icon={<SyncOutlined />}

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

@@ -6,7 +6,7 @@ import { setModalContext } from "../../redux/modals/modals.actions";
import { store } from "../../redux/store";
import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { TimeFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { onlyUnique } from "../../utils/arrayHelper";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { alphaSort, dateSort, statusSort } from "../../utils/sorters";
@@ -28,6 +28,7 @@ import ProductionListColumnCategory from "./production-list-columns.status.categ
import ProductionListColumnStatus from "./production-list-columns.status.component";
import ProductionListColumnTouchTime from "./prodution-list-columns.touchtime.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import ChatOpenButton from "../chat-open-button/chat-open-button.component.jsx";
const getEmployeeName = (employeeId, employees) => {
const employee = employees.find((e) => e.id === employeeId);
@@ -271,14 +272,24 @@ const productionListColumnsData = ({ technician, state, activeStatuses, data, bo
dataIndex: "ownr_ph1",
key: "ownr_ph1",
ellipsis: true,
render: (text, record) => <PhoneFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneFormatter>
render: (text, record) =>
technician ? (
<PhoneNumberFormatter type={record.ownr_ph1_ty}>{record.ownr_ph1}</PhoneNumberFormatter>
) : (
<ChatOpenButton type={record.ownr_ph1_ty} phone={record.ownr_ph1} jobid={record.id} />
)
},
{
title: i18n.t("jobs.fields.ownr_ph2"),
dataIndex: "ownr_ph2",
key: "ownr_ph2",
ellipsis: true,
render: (text, record) => <PhoneFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneFormatter>
render: (text, record) =>
technician ? (
<PhoneNumberFormatter type={record.ownr_ph2_ty}>{record.ownr_ph2}</PhoneNumberFormatter>
) : (
<ChatOpenButton type={record.ownr_ph2_ty} phone={record.ownr_ph2} jobid={record.id} />
)
},
{
title: i18n.t("jobs.fields.specialcoveragepolicy"),

View File

@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets);
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationErrors = (presets = [], t) => {
const totalsByLaborType = {};
presets.forEach((preset) => {
const percent = normalizePercent(preset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(preset?.hourstype) ? preset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
return Object.entries(totalsByLaborType)
.filter(([, total]) => total > 100)
.map(([laborType, total]) => {
const translatedLaborType = t(`joblines.fields.lbr_types.${laborType}`);
const laborTypeLabel =
translatedLaborType === `joblines.fields.lbr_types.${laborType}` ? laborType : translatedLaborType;
return t("bodyshop.errors.task_preset_allocation_exceeded", {
laborType: laborTypeLabel,
total
});
});
};
export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation();
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
<Form.List name={["md_tasks_presets", "presets"]}>
{(fields, { add, remove, move }) => {
<Form.List
name={["md_tasks_presets", "presets"]}
rules={[
{
validator: async (_, presets) => {
const allocationErrors = getTaskPresetAllocationErrors(presets, t);
if (allocationErrors.length > 0) {
throw new Error(allocationErrors.join(" "));
}
}
}
]}
>
{(fields, { add, remove, move }, { errors }) => {
return (
<div>
{fields.map((field, index) => (
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow>
</Form.Item>
))}
<Form.ErrorList errors={errors} />
<Form.Item>
<Button
type="dashed"

View File

@@ -1,9 +1,23 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
import {
Button,
Card,
Col,
Form,
Input,
InputNumber,
Row,
Select,
Skeleton,
Space,
Switch,
Tag,
Typography
} from "antd";
import querystring from "query-string";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
@@ -22,13 +36,51 @@ 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 = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ 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 getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
const employee = employees.find((currentEmployee) => currentEmployee.id === employeeId);
if (!employee) return null;
const fullName = [employee.first_name, employee.last_name].filter(Boolean).join(" ").trim();
return fullName || employee.employee_number || null;
};
const formatAllocationPercentage = (percentage) => {
if (percentage === null || percentage === undefined || percentage === "") return null;
const numericValue = Number(percentage);
if (!Number.isFinite(numericValue)) return null;
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
};
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation();
@@ -36,47 +88,110 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const history = useNavigate();
const search = querystring.parse(useLocation().search);
const notification = useNotification();
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
const isNewTeam = search.employeeTeamId === "new";
const { error, data } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || search.employeeTeamId === "new",
skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only",
nextFetchPolicy: "network-only"
nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
});
useEffect(() => {
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
else {
if (!search.employeeTeamId) return;
if (isNewTeam) {
form.resetFields();
setHydratedTeamId("new");
return;
}
}, [form, data, search.employeeTeamId]);
setHydratedTeamId(null);
}, [form, isNewTeam, search.employeeTeamId]);
useEffect(() => {
if (!search.employeeTeamId || isNewTeam || loading) return;
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
setHydratedTeamId(search.employeeTeamId);
} else {
form.resetFields();
setHydratedTeamId(search.employeeTeamId);
}
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
label: t(labelKey),
value
}));
const teamName = Form.useWatch("name", form);
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
const teamCardTitle = isTeamHydrating
? t("employee_teams.fields.name")
: 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);
const payoutMethod =
teamMember.payout_method === "commission"
? t("employee_teams.options.commission")
: t("employee_teams.options.hourly");
return (
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8 }}>
<Typography.Text strong>{employeeName}</Typography.Text>
<Tag variant="filled" color="geekblue">
{`${t("employee_teams.fields.allocation")}: ${allocation || "--"}`}
</Tag>
<Tag variant="filled" color={getPayoutMethodTagColor(teamMember.payout_method)}>
{payoutMethod}
</Tag>
</div>
);
};
const handleFinish = async ({ employee_team_members = [], ...values }) => {
const { normalizedTeamMembers, errorKey } = validateEmployeeTeamMembers(employee_team_members);
if (errorKey) {
notification.error({
title: t(errorKey)
});
return;
}
const handleFinish = async ({ employee_team_members, ...values }) => {
if (search.employeeTeamId && search.employeeTeamId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({
variables: {
employeeTeamId: search.employeeTeamId,
employeeTeam: values,
teamMemberUpdates: employee_team_members
.filter((e) => e.id)
.map((e) => {
delete e.__typename;
return { where: { id: { _eq: e.id } }, _set: e };
}),
teamMemberInserts: employee_team_members
.filter((e) => e.id === null || e.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
(e) => !employee_team_members.find((etm) => etm.id === e.id)
)
teamMemberUpdates: normalizedTeamMembers
.filter((teamMember) => teamMember.id)
.map((teamMember) => ({
where: { id: { _eq: teamMember.id } },
_set: teamMember
})),
teamMemberInserts: normalizedTeamMembers
.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)
)
.map((teamMember) => teamMember.id)
}
});
if (!result.errors) {
notification.success({
title: t("employees.successes.save")
@@ -89,20 +204,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
}
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployeeTeam({
variables: {
employeeTeam: {
...values,
employee_team_members: { data: employee_team_members },
employee_team_members: { data: normalizedTeamMembers },
bodyshopid: bodyshop.id
}
},
refetchQueries: ["QUERY_TEAMS"]
}).then((r) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id;
}).then((response) => {
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
title: t("employees.successes.save")
@@ -116,288 +230,196 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return (
<Card
title={teamCardTitle}
extra={
<Button type="primary" onClick={() => form.submit()}>
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
{t("general.actions.save")}
</Button>
}
>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow>
<Form.Item
name="name"
label={t("employee_teams.fields.name")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.max_load")}
name="max_load"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</LayoutFormRow>
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow grow>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAA")}
key={`${index}`}
name={[field.name, "labor_rates", "LAA"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAB")}
key={`${index}`}
name={[field.name, "labor_rates", "LAB"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAD")}
key={`${index}`}
name={[field.name, "labor_rates", "LAD"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAE")}
key={`${index}`}
name={[field.name, "labor_rates", "LAE"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
{isTeamHydrating ? (
<Skeleton active title={false} paragraph={{ rows: 12 }} />
) : (
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<LayoutFormRow>
<Form.Item
name="name"
label={t("employee_teams.fields.name")}
rules={[
{
required: true
}
]}
>
<Input />
</Form.Item>
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.max_load")}
name="max_load"
rules={[
{
required: true
}
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</LayoutFormRow>
<Form.List name={["employee_team_members"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => {
const teamMember = normalizeTeamMember(teamMembers[field.name]);
<Form.Item
label={t("joblines.fields.lbr_types.LAF")}
key={`${index}`}
name={[field.name, "labor_rates", "LAF"]}
rules={[
{
required: true
//message: t("general.validation.required"),
return (
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
<Input type="hidden" />
</Form.Item>
<LayoutFormRow
grow
title={getTeamMemberTitle(teamMember)}
extra={
<Space align="center" size="small">
<Button
type="text"
icon={<DeleteFilled />}
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows
move={move}
index={index}
total={fields.length}
orientation="horizontal"
/>
</Space>
}
]}
>
<CurrencyInput />
>
<div>
<Row gutter={[16, 0]}>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.employee}>
<Form.Item
label={t("employee_teams.fields.employeeid")}
key={`${index}`}
name={[field.name, "employeeid"]}
rules={[
{
required: true
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.allocation}>
<Form.Item
label={t("employee_teams.fields.allocation_percentage")}
key={`${index}`}
name={[field.name, "percentage"]}
rules={[
{
required: true
}
]}
>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Col>
<Col {...TEAM_MEMBER_PRIMARY_FIELD_COLS.payoutMethod}>
<Form.Item
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
rules={[
{
required: true
}
]}
>
<Select options={payoutMethodOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
"hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
return (
<Row gutter={[16, 0]}>
{LABOR_TYPES.map((laborType) => (
<Col {...TEAM_MEMBER_RATE_FIELD_COLS} key={`${index}-${fieldName}-${laborType}`}>
<Form.Item
label={t(`joblines.fields.lbr_types.${laborType}`)}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
</Col>
))}
</Row>
);
}}
</Form.Item>
</div>
</LayoutFormRow>
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAG")}
key={`${index}`}
name={[field.name, "labor_rates", "LAG"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAM")}
key={`${index}`}
name={[field.name, "labor_rates", "LAM"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAR")}
key={`${index}`}
name={[field.name, "labor_rates", "LAR"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAS")}
key={`${index}`}
name={[field.name, "labor_rates", "LAS"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAU")}
key={`${index}`}
name={[field.name, "labor_rates", "LAU"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA1")}
key={`${index}`}
name={[field.name, "labor_rates", "LA1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA2")}
key={`${index}`}
name={[field.name, "labor_rates", "LA2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA3")}
key={`${index}`}
name={[field.name, "labor_rates", "LA3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA4")}
key={`${index}`}
name={[field.name, "labor_rates", "LA4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Space align="center">
<DeleteFilled
onClick={() => {
remove(field.name);
}}
/>
<FormListMoveArrows move={move} index={index} total={fields.length} />
</Space>
</LayoutFormRow>
);
})}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
</Form>
</Form.Item>
</div>
);
}}
</Form.List>
</Form>
)}
</Card>
);
}

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

@@ -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();
@@ -35,7 +47,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
</Form.Item>
<Space wrap>
<Form.Item name="task" label={t("timetickets.labels.task")}>
<Form.Item
name="task"
label={t("timetickets.labels.task")}
rules={[
{
required: true
}
]}
>
{loading ? (
<Spin />
) : (
@@ -93,33 +113,51 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.payout_method")}</th>
<th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
<ReadOnlyFormItemComponent type="employee" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
</tr>
))}
{fields.map((field, index) => {
const payoutMethod = form.getFieldValue(["timetickets", field.name, "payout_context", "payout_method"]);
return (
<tr key={field.key}>
<td>
<Form.Item key={`${index}employeeid`} name={[field.name, "employeeid"]}>
<ReadOnlyFormItemComponent type="employee" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}cost_center`} name={[field.name, "cost_center"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}ciecacode`} name={[field.name, "ciecacode"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}productivehrs`} name={[field.name, "productivehrs"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>{getPayoutMethodLabel(payoutMethod, t)}</td>
<td>
<Form.Item key={`${index}rate`} name={[field.name, "rate"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item key={`${index}payoutamount`} name={[field.name, "payoutamount"]}>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
</tr>
);
})}
</tbody>
</table>
<Alert type="success" title={t("timetickets.labels.payrollclaimedtasks")} />

View File

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

@@ -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;
@@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
if (actions?.refetch) actions.refetch();
toggleModalVisible();
} else if (handleFinish === false) {
form.setFieldsValue({ timetickets: data.ticketsToInsert });
form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket,
payoutamount: getPreviewPayoutAmount(ticket)
}))
});
setUnassignedHours(data.unassignedHours);
} else {
notification.error({
@@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
}
} catch (error) {
notification.error({
title: t("timetickets.errors.creating", { message: error.message })
title: t("timetickets.errors.creating", {
message: error.response?.data?.error || error.message
})
});
} finally {
setLoading(false);

View File

@@ -130,7 +130,15 @@ export function TtApprovalsListComponent({
key: "memo",
sorter: (a, b) => alphaSort(a.memo, b.memo),
sortOrder: state.sortedInfo.columnKey === "memo" && state.sortedInfo.order,
render: (text, record) => (record.clockon || record.clockoff ? t(record.memo) : record.memo)
render: (text, record) => (record.memo?.startsWith("timetickets.labels") ? t(record.memo) : record.memo)
},
{
title: t("timetickets.fields.task_name"),
dataIndex: "task_name",
key: "task_name",
sorter: (a, b) => alphaSort(a.task_name, b.task_name),
sortOrder: state.sortedInfo.columnKey === "task_name" && state.sortedInfo.order,
render: (text, record) => record.task_name || ""
},
{
title: t("timetickets.fields.clockon"),
@@ -140,12 +148,12 @@ export function TtApprovalsListComponent({
render: (text, record) => <DateTimeFormatter>{record.clockon}</DateTimeFormatter>
},
{
title: "Pay",
title: t("timetickets.fields.pay"),
dataIndex: "pay",
key: "pay",
render: (text, record) =>
Dinero({ amount: Math.round(record.rate * 100) })
.multiply(record.flat_rate ? record.productivehrs : record.actualhrs)
Dinero({ amount: Math.round((record.rate || 0) * 100) })
.multiply(record.flat_rate ? record.productivehrs || 0 : record.actualhrs || 0)
.toFormat("$0.00")
}
];
@@ -184,7 +192,7 @@ export function TtApprovalsListComponent({
<ResponsiveTable
loading={loading}
columns={columns}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center"]}
mobileColumnKeys={["ro_number", "date", "employeeid", "cost_center", "task_name"]}
rowKey="id"
scroll={{
x: true

View File

@@ -18,7 +18,15 @@ const mapStateToProps = createStructuredSelector({
authLevel: selectAuthLevel
});
export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabled, authLevel }) {
export function TtApproveButton({
bodyshop,
currentUser,
selectedTickets,
disabled,
authLevel,
completedCallback,
refetch
}) {
const { t } = useTranslation();
const client = useApolloClient();
const notification = useNotification();
@@ -54,6 +62,12 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
})
});
} else {
if (typeof completedCallback === "function") {
completedCallback([]);
}
if (typeof refetch === "function") {
refetch();
}
notification.success({
title: t("timetickets.successes.created")
});
@@ -68,8 +82,6 @@ export function TtApproveButton({ bodyshop, currentUser, selectedTickets, disabl
setLoading(false);
}
// if (!!completedCallback) completedCallback([]);
// if (!!loadingCallback) loadingCallback(false);
};
return (

View File

@@ -152,6 +152,8 @@ export const QUERY_BODYSHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -285,6 +287,8 @@ export const UPDATE_SHOP = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -10,6 +10,8 @@ export const QUERY_TEAMS = gql`
id
employeeid
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -29,6 +31,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -40,6 +44,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -52,6 +58,8 @@ export const UPDATE_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -69,6 +77,8 @@ export const INSERT_EMPLOYEE_TEAM = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}
@@ -86,6 +96,8 @@ export const QUERY_EMPLOYEE_TEAM_BY_ID = gql`
employeeid
id
labor_rates
payout_method
commission_rates
percentage
}
}

View File

@@ -260,6 +260,7 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
id
clockon
clockoff
created_by
employeeid
productivehrs
actualhrs
@@ -267,6 +268,9 @@ export const INSERT_TIME_TICKET_AND_APPROVE = gql`
date
memo
flat_rate
task_name
payout_context
ttapprovalqueueid
commited_by
committed_at
}

View File

@@ -23,7 +23,14 @@ export const QUERY_ALL_TT_APPROVALS_PAGINATED = gql`
ciecacode
cost_center
date
memo
flat_rate
clockon
clockoff
rate
created_by
task_name
payout_context
}
tt_approval_queue_aggregate {
aggregate {
@@ -42,9 +49,16 @@ export const INSERT_NEW_TT_APPROVALS = gql`
productivehrs
actualhrs
ciecacode
cost_center
date
memo
flat_rate
rate
clockon
clockoff
created_by
task_name
payout_context
}
}
}
@@ -65,6 +79,11 @@ export const QUERY_TT_APPROVALS_BY_IDS = gql`
ciecacode
bodyshopid
cost_center
clockon
clockoff
created_by
task_name
payout_context
}
}
`;

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}"
"saving": "Error encountered while saving. {{message}}",
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1175,12 +1176,28 @@
"new": "New Team",
"newmember": "New Team Member"
},
"errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.",
"duplicate_member": "Each employee can only appear once per team.",
"minimum_one_member": "Add at least one team member."
},
"fields": {
"active": "Active",
"allocation": "Allocation",
"allocation_percentage": "Allocation %",
"employeeid": "Employee",
"max_load": "Max Load",
"name": "Team Name",
"payout_method": "Payout Method",
"percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
"commission": "Commission",
"commission_percentage": "Commission %",
"hourly": "Hourly"
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "Actual Hours",
"amount": "Amount",
"ciecacode": "CIECA Code",
"clockhours": "Clock Hours",
"clockoff": "Clock Off",
@@ -3608,7 +3626,10 @@
"employee_team": "Employee Team",
"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"
},
@@ -3627,6 +3648,10 @@
"lunch": "Lunch",
"new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"payout_methods": {
"commission": "Commission",
"hourly": "Hourly"
},
"pmbreak": "PM Break",
"pmshift": "PM Shift",
"shift": "Shift",

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,28 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission": "",
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "",
"amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3608,7 +3626,10 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"payout_method": "",
"productivehrs": "",
"rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3627,6 +3648,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "",
"pmshift": "",
"shift": "",

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,28 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission": "",
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -3594,6 +3611,7 @@
},
"fields": {
"actualhrs": "",
"amount": "",
"ciecacode": "",
"clockhours": "",
"clockoff": "",
@@ -3608,7 +3626,10 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"payout_method": "",
"productivehrs": "",
"rate": "",
"ro_number": "",
"task_name": ""
},
@@ -3627,6 +3648,10 @@
"lunch": "",
"new": "",
"payrollclaimedtasks": "",
"payout_methods": {
"commission": "",
"hourly": ""
},
"pmbreak": "",
"pmshift": "",
"shift": "",

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

@@ -2156,10 +2156,12 @@
- active:
_eq: true
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -2167,10 +2169,12 @@
- role: user
permission:
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -2188,10 +2192,12 @@
- role: user
permission:
columns:
- commission_rates
- created_at
- employeeid
- id
- labor_rates
- payout_method
- percentage
- teamid
- updated_at
@@ -6506,6 +6512,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6531,6 +6538,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6565,6 +6573,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- task_name
@@ -6748,6 +6757,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at
@@ -6768,6 +6778,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at
@@ -6798,6 +6809,7 @@
- id
- jobid
- memo
- payout_context
- productivehrs
- rate
- updated_at

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."employee_team_members" add column "payout_method" text
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."employee_team_members" add column "payout_method" text
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."employee_team_members" add column "commission_rates" jsonb
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."employee_team_members" add column "commission_rates" jsonb
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."timetickets" add column "payout_context" jsonb
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."timetickets" add column "payout_context" jsonb
null;

View File

@@ -0,0 +1,4 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."tt_approval_queue" add column "payout_context" jsonb
-- null;

View File

@@ -0,0 +1,2 @@
alter table "public"."tt_approval_queue" add column "payout_context" jsonb
null;

2346
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,25 +18,25 @@
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
},
"dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.997.0",
"@aws-sdk/client-elasticache": "^3.997.0",
"@aws-sdk/client-s3": "^3.997.0",
"@aws-sdk/client-secrets-manager": "^3.997.0",
"@aws-sdk/client-ses": "^3.997.0",
"@aws-sdk/client-sqs": "^3.997.0",
"@aws-sdk/client-textract": "^3.997.0",
"@aws-sdk/credential-provider-node": "^3.972.12",
"@aws-sdk/lib-storage": "^3.997.0",
"@aws-sdk/s3-request-presigner": "^3.997.0",
"@aws-sdk/client-cloudwatch-logs": "^3.1010.0",
"@aws-sdk/client-elasticache": "^3.1010.0",
"@aws-sdk/client-s3": "^3.1010.0",
"@aws-sdk/client-secrets-manager": "^3.1010.0",
"@aws-sdk/client-ses": "^3.1010.0",
"@aws-sdk/client-sqs": "^3.1010.0",
"@aws-sdk/client-textract": "^3.1010.0",
"@aws-sdk/credential-provider-node": "^3.972.21",
"@aws-sdk/lib-storage": "^3.1010.0",
"@aws-sdk/s3-request-presigner": "^3.1010.0",
"@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1",
"aws4": "^1.13.2",
"axios": "^1.13.5",
"axios": "^1.13.6",
"axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12",
"bullmq": "^5.70.1",
"bullmq": "^5.71.0",
"chart.js": "^4.5.1",
"cloudinary": "^2.9.0",
"compression": "^1.8.1",
@@ -46,20 +46,20 @@
"dinero.js": "^1.9.1",
"dotenv": "^17.3.1",
"express": "^4.21.1",
"fast-xml-parser": "^5.4.1",
"firebase-admin": "^13.6.1",
"fast-xml-parser": "^5.5.6",
"firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0",
"graphql": "^16.13.0",
"graphql": "^16.13.1",
"graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2",
"ioredis": "^5.9.3",
"ioredis": "^5.10.0",
"json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3",
"juice": "^11.1.1",
"lodash": "^4.17.23",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"multer": "^2.0.2",
"multer": "^2.1.1",
"mustache": "^4.2.0",
"node-persist": "^4.0.4",
"nodemailer": "^6.10.0",
@@ -69,15 +69,15 @@
"recursive-diff": "^1.0.9",
"rimraf": "^6.1.3",
"skia-canvas": "^3.0.8",
"soap": "^1.7.1",
"soap": "^1.8.0",
"socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0",
"twilio": "^5.12.2",
"twilio": "^5.13.0",
"uuid": "^11.1.0",
"winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0",
"xml-formatter": "^3.6.7",
"xml-formatter": "^3.7.0",
"xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3",
"yazl": "^3.3.1"
@@ -86,11 +86,11 @@
"@eslint/js": "^9.39.2",
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.3.0",
"globals": "^17.4.0",
"mock-require": "^3.0.3",
"p-limit": "^3.1.0",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"vitest": "^4.0.18"
"vitest": "^4.1.0"
}
}

View File

@@ -2463,6 +2463,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2473,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -2564,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2574,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number

View File

@@ -1,7 +1,7 @@
const sendPaymentNotificationEmail = require("./sendPaymentNotificationEmail");
const { INSERT_NEW_PAYMENT, GET_BODYSHOP_BY_ID, GET_JOBS_BY_PKS } = require("../../graphql-client/queries");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const moment = require("moment-timezone");
const gqlClient = require("../../graphql-client/graphql-client").client;

View File

@@ -8,7 +8,7 @@ const {
const { sendTaskEmail } = require("../../email/sendemail");
const getPaymentType = require("./getPaymentType");
const moment = require("moment");
const moment = require("moment-timezone");
const gqlClient = require("../../graphql-client/graphql-client").client;

View File

@@ -1,5 +1,18 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn()
}));
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
const getPaymentType = require("../getPaymentType");
const decodeComment = require("../decodeComment");
const getCptellerUrl = require("../getCptellerUrl");
@@ -145,28 +158,15 @@ describe("Payment Processing Functions", () => {
// GetShopCredentials Tests
describe("getShopCredentials", () => {
const originalEnv = { ...process.env };
let mockSend;
beforeEach(() => {
mockSend = vi.fn();
vi.mock("@aws-sdk/client-secrets-manager", () => {
return {
SecretsManagerClient: vi.fn(() => ({
send: mockSend
})),
GetSecretValueCommand: vi.fn((input) => input)
};
});
mockSend.mockReset();
process.env.INTELLIPAY_MERCHANTKEY = "test-merchant-key";
process.env.INTELLIPAY_APIKEY = "test-api-key";
vi.resetModules();
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
vi.unmock("@aws-sdk/client-secrets-manager");
});
it("returns environment variables in non-production environment", async () => {

View File

@@ -35,6 +35,11 @@ describe("TotalsServerSide fixture tests", () => {
const fixtureFiles = fs.readdirSync(fixturesDir).filter((f) => f.endsWith(".json"));
if (fixtureFiles.length === 0) {
it.skip("skips when no job total fixtures are present", () => {});
return;
}
const dummyClient = {
request: async () => {
return {};

View File

@@ -1,20 +1,9 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const get = (obj, key) => {
return key.split(".").reduce((o, x) => {
return typeof o == "undefined" || o === null ? o : o[x];
}, obj);
};
exports.calculatelabor = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey];
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
const expected = employeeHash[employeeIdKey][laborTypeKey];
const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
if (claimed) {
delete ticketHash[employeeIdKey][laborTypeKey];
}
totals.push({
employeeid: employeeIdKey,
rate: expected.rate,
mod_lbr_ty: laborTypeKey,
expectedHours: expected.hours,
claimedHours: claimed?.hours || 0
});
});
});
@@ -65,23 +50,14 @@ exports.calculatelabor = async function (req, res) {
Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(ticketHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = 0;
//Will the following line fail? Probably if it doesn't exist.
const claimedHours = get(ticketHash, `${employeeIdKey}.${laborTypeKey}.${rateKey}`);
if (claimedHours) {
delete ticketHash[employeeIdKey][laborTypeKey][rateKey];
}
const claimed = ticketHash[employeeIdKey][laborTypeKey];
totals.push({
employeeid: employeeIdKey,
rate: rateKey,
mod_lbr_ty: laborTypeKey,
expectedHours,
claimedHours: claimedHours || 0
});
totals.push({
employeeid: employeeIdKey,
rate: claimed.rate,
mod_lbr_ty: laborTypeKey,
expectedHours: 0,
claimedHours: claimed.hours || 0
});
});
});
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ error: error.message });
}
};

View File

@@ -1,11 +1,42 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob } = require("./pay-all");
const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
const moment = require("moment");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
const getTaskPresetAllocationError = (taskPresets = []) => {
const totalsByLaborType = {};
taskPresets.forEach((taskPreset) => {
const percent = normalizePercent(taskPreset?.percent);
if (!percent) {
return;
}
const laborTypes = Array.isArray(taskPreset?.hourstype) ? taskPreset.hourstype : [];
laborTypes.forEach((laborType) => {
if (!laborType) {
return;
}
totalsByLaborType[laborType] = normalizePercent((totalsByLaborType[laborType] || 0) + percent);
});
});
const overAllocatedType = Object.entries(totalsByLaborType).find(([, total]) => total > 100);
if (!overAllocatedType) {
return null;
}
const [laborType, total] = overAllocatedType;
return `Task preset percentages for labor type ${laborType} total ${total}% and cannot exceed 100%.`;
};
exports.GetTaskPresetAllocationError = getTaskPresetAllocationError;
exports.claimtask = async function (req, res) {
const { jobid, task, calculateOnly, employee } = req.body;
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
id: jobid
});
const theTaskPreset = job.bodyshop.md_tasks_presets.presets.find((tp) => tp.name === task);
const taskPresets = job.bodyshop?.md_tasks_presets?.presets || [];
const taskPresetAllocationError = getTaskPresetAllocationError(taskPresets);
if (taskPresetAllocationError) {
res.status(400).json({ success: false, error: taskPresetAllocationError });
return;
}
const theTaskPreset = taskPresets.find((tp) => tp.name === task);
if (!theTaskPreset) {
res.status(400).json({ success: false, error: "Provided task preset not found." });
return;
}
const taskAlreadyCompleted = (job.completed_tasks || []).some((completedTask) => completedTask?.name === task);
if (taskAlreadyCompleted) {
res.status(400).json({ success: false, error: "Provided task preset has already been completed for this job." });
return;
}
//Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
const ticketsToInsert = [];
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => {
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
const expected = employeeHash[employeeIdKey][laborTypeKey];
const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
ticketsToInsert.push({
task_name: task,
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: rateKey,
ciecacode: laborTypeKey,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
memo: `*Flagged Task* ${theTaskPreset.memo}`
});
ticketsToInsert.push({
task_name: task,
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey,
productivehrs: expectedHours,
rate: expected.rate,
ciecacode: laborTypeKey,
flat_rate: true,
created_by: employee?.name || req.user.email,
payout_context: {
...(expected.payoutContext || {}),
generated_by: req.user.email,
generated_at: new Date().toISOString(),
generated_from: "claimtask",
task_name: task
},
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey],
memo: `*Flagged Task* ${theTaskPreset.memo}`
});
});
});
if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them.
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
const updateResult = await client.request(queries.UPDATE_JOB, {
await client.request(queries.UPDATE_JOB, {
jobId: job.id,
job: {
status: theTaskPreset.nextstatus,
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
jobid: jobid,
error
});
res.status(503).send();
res.status(400).json({ success: false, error: error.message });
}
};

View File

@@ -1,15 +1,196 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN";
Dinero.globalFormatRoundingMode = "HALF_EVEN";
const PAYOUT_METHODS = {
hourly: "hourly",
commission: "commission"
};
const CURRENCY_PRECISION = 2;
const HOURS_PRECISION = 5;
const toNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const normalizeNumericString = (value) => {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
const asString = value.toString();
if (!asString.toLowerCase().includes("e")) {
return asString;
}
return value.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
}
return `${value ?? ""}`.trim();
};
const decimalToDinero = (value, errorMessage = "Invalid numeric value.") => {
const normalizedValue = normalizeNumericString(value);
const parsedValue = Number(normalizedValue);
if (!Number.isFinite(parsedValue)) {
throw new Error(errorMessage);
}
const isNegative = normalizedValue.startsWith("-");
const unsignedValue = normalizedValue.replace(/^[+-]/, "");
const [wholePart = "0", fractionPartRaw = ""] = unsignedValue.split(".");
const wholeDigits = wholePart.replace(/\D/g, "") || "0";
const fractionDigits = fractionPartRaw.replace(/\D/g, "");
const amount = Number(`${wholeDigits}${fractionDigits}` || "0") * (isNegative ? -1 : 1);
return Dinero({
amount,
precision: fractionDigits.length
});
};
const roundValueWithDinero = (value, precision, errorMessage) =>
decimalToDinero(value, errorMessage).convertPrecision(precision, Dinero.globalRoundingMode).toUnit();
const roundCurrency = (value, errorMessage = "Invalid currency value.") =>
roundValueWithDinero(value, CURRENCY_PRECISION, errorMessage);
const roundHours = (value, errorMessage = "Invalid hours value.") => roundValueWithDinero(value, HOURS_PRECISION, errorMessage);
const normalizePayoutMethod = (value) =>
value === PAYOUT_METHODS.commission ? PAYOUT_METHODS.commission : PAYOUT_METHODS.hourly;
const hasOwnValue = (obj, key) => Object.prototype.hasOwnProperty.call(obj || {}, key);
const getJobSaleRateField = (laborType) => `rate_${String(laborType || "").toLowerCase()}`;
const getTeamMemberLabel = (teamMember) => {
const fullName = `${teamMember?.employee?.first_name || ""} ${teamMember?.employee?.last_name || ""}`.trim();
return fullName || teamMember?.employee?.id || teamMember?.employeeid || "unknown employee";
};
const parseRequiredNumber = (value, errorMessage) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new Error(errorMessage);
}
return parsed;
};
const buildFallbackPayoutContext = ({ laborType, rate }) => ({
payout_type: "legacy",
payout_method: "legacy",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: laborType,
effective_rate: roundCurrency(rate)
});
function BuildPayoutDetails(job, teamMember, laborType) {
const payoutMethod = normalizePayoutMethod(teamMember?.payout_method);
const teamMemberLabel = getTeamMemberLabel(teamMember);
const sourceLaborRateField = getJobSaleRateField(laborType);
if (payoutMethod === PAYOUT_METHODS.hourly && !hasOwnValue(teamMember?.labor_rates, laborType)) {
throw new Error(`Missing hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(teamMember?.commission_rates, laborType)) {
throw new Error(`Missing commission percent for ${teamMemberLabel} on labor type ${laborType}.`);
}
if (payoutMethod === PAYOUT_METHODS.commission && !hasOwnValue(job, sourceLaborRateField)) {
throw new Error(`Missing sale rate ${sourceLaborRateField} for labor type ${laborType}.`);
}
const hourlyRate =
payoutMethod === PAYOUT_METHODS.hourly
? roundCurrency(
parseRequiredNumber(
teamMember?.labor_rates?.[laborType],
`Invalid hourly payout rate for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
const commissionPercent =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(
teamMember?.commission_rates?.[laborType],
`Invalid commission percent for ${teamMemberLabel} on labor type ${laborType}.`
)
)
: null;
if (commissionPercent !== null && (commissionPercent < 0 || commissionPercent > 100)) {
throw new Error(`Commission percent for ${teamMemberLabel} on labor type ${laborType} must be between 0 and 100.`);
}
const sourceLaborRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency(
parseRequiredNumber(job?.[sourceLaborRateField], `Invalid sale rate ${sourceLaborRateField} for labor type ${laborType}.`)
)
: null;
const effectiveRate =
payoutMethod === PAYOUT_METHODS.commission
? roundCurrency((sourceLaborRate * toNumber(commissionPercent)) / 100)
: hourlyRate;
return {
effectiveRate,
payoutContext: {
payout_type: payoutMethod === PAYOUT_METHODS.commission ? "cut" : "hourly",
payout_method: payoutMethod,
cut_percent_applied: commissionPercent,
source_labor_rate: sourceLaborRate,
source_labor_type: laborType,
effective_rate: effectiveRate
}
};
}
function BuildGeneratedPayoutContext({ baseContext, generatedBy, generatedFrom, taskName, usedTicketFallback }) {
return {
...(baseContext || {}),
generated_by: generatedBy,
generated_at: new Date().toISOString(),
generated_from: generatedFrom,
task_name: taskName,
used_ticket_fallback: Boolean(usedTicketFallback)
};
}
function getAllKeys(...objects) {
return [...new Set(objects.flatMap((obj) => (obj ? Object.keys(obj) : [])))];
}
function buildPayAllMemo({ deltaHours, hasExpected, hasClaimed, userEmail }) {
if (!hasClaimed && deltaHours > 0) {
return `Add unflagged hours. (${userEmail})`;
}
if (!hasExpected && deltaHours < 0) {
return `Remove flagged hours per assignment. (${userEmail})`;
}
return `Adjust flagged hours per assignment. (${userEmail})`;
}
exports.payall = async function (req, res) {
const { jobid, calculateOnly } = req.body;
const { jobid } = req.body;
logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken;
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
id: jobid
});
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Not all hours have been assigned." });
return;
}
//Calculate how much time each tech should have by labor type.
//Doing this order creates a diff of changes on the ticket hash to make it the same as the employee hash.
const recursiveDiff = rdiff.getDiff(ticketHash, employeeHash, true);
const ticketsToInsert = [];
const employeeIds = getAllKeys(employeeHash, ticketHash);
recursiveDiff.forEach((diff) => {
//Every iteration is what we would need to insert into the time ticket hash
//so that it would match the employee hash exactly.
const path = diffParser(diff);
employeeIds.forEach((employeeId) => {
const expectedByLabor = employeeHash[employeeId] || {};
const claimedByLabor = ticketHash[employeeId] || {};
if (diff.op === "add") {
// console.log(Object.keys(diff.val));
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) {
//Multiple values to add.
Object.keys(diff.val).forEach((key) => {
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]);
// console.log("Rate", Object.keys(diff.val[key])[0]);
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val[key][Object.keys(diff.val[key])[0]],
rate: Object.keys(diff.val[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `Add unflagged hours. (${req.user.email})`
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Add unflagged hours. (${req.user.email})`
});
getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
const expected = expectedByLabor[laborType];
const claimed = claimedByLabor[laborType];
const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
if (deltaHours === 0) {
return;
}
} else if (diff.op === "update") {
//An old ticket amount isn't sufficient
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead.
const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
const payoutContext = BuildGeneratedPayoutContext({
baseContext:
expected?.payoutContext ||
claimed?.payoutContext ||
buildFallbackPayoutContext({ laborType, rate: effectiveRate }),
generatedBy: req.user.email,
generatedFrom: "payall",
taskName: "Pay All",
usedTicketFallback: !expected && Boolean(claimed)
});
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.val - diff.oldVal,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
employeeid: employeeId,
productivehrs: deltaHours,
rate: effectiveRate,
ciecacode: laborType,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
memo: `Adjust flagged hours per assignment. (${req.user.email})`
created_by: req.user.email,
payout_context: payoutContext,
memo: buildPayAllMemo({
deltaHours,
hasExpected: Boolean(expected),
hasClaimed: Boolean(claimed),
userEmail: req.user.email
})
});
} else {
//Has to be a delete
if (typeof diff.oldVal === "object" && Object.keys(diff.oldVal).length > 1) {
//Multiple oldValues to add.
Object.keys(diff.oldVal).forEach((key) => {
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: diff.oldVal[key][Object.keys(diff.oldVal[key])[0]] * -1,
rate: Object.keys(diff.oldVal[key])[0],
ciecacode: key,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[key],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
});
} else {
//Only the 1 value to add.
ticketsToInsert.push({
task_name: "Pay All",
jobid: job.id,
bodyshopid: job.bodyshop.id,
employeeid: path.employeeid,
productivehrs: path.hours * -1,
rate: path.rate,
ciecacode: path.mod_lbr_ty,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty],
flat_rate: true,
memo: `Remove flagged hours per assignment. (${req.user.email})`
});
}
}
});
});
const insertResult = await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
});
const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
res.json(ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0));
if (filteredTickets.length > 0) {
await client.request(queries.INSERT_TIME_TICKETS, {
timetickets: filteredTickets
});
}
res.json(filteredTickets);
} catch (error) {
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
jobid: jobid,
jobid,
error: JSON.stringify(error)
});
res.status(400).json({ error: error.message });
}
};
function diffParser(diff) {
const type = typeof diff.oldVal;
let mod_lbr_ty, rate, hours;
if (diff.path.length === 1) {
if (diff.op === "add") {
mod_lbr_ty = Object.keys(diff.val)[0];
rate = Object.keys(diff.val[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
} else {
mod_lbr_ty = Object.keys(diff.oldVal)[0];
rate = Object.keys(diff.oldVal[mod_lbr_ty])[0];
// hours = diff.oldVal[mod_lbr_ty][rate];
}
} else if (diff.path.length === 2) {
mod_lbr_ty = diff.path[1];
if (diff.op === "add") {
rate = Object.keys(diff.val)[0];
} else {
rate = Object.keys(diff.oldVal)[0];
}
} else if (diff.path.length === 3) {
mod_lbr_ty = diff.path[1];
rate = diff.path[2];
//hours = 0;
}
//Set the hours
if (typeof diff.val === "number" && diff.val !== null && diff.val !== undefined) {
hours = diff.val;
} else if (diff.val !== null && diff.val !== undefined) {
if (diff.path.length === 1) {
hours = diff.val[Object.keys(diff.val)[0]][Object.keys(diff.val[Object.keys(diff.val)[0]])];
} else {
hours = diff.val[Object.keys(diff.val)[0]];
}
} else if (typeof diff.oldVal === "number" && diff.oldVal !== null && diff.oldVal !== undefined) {
hours = diff.oldVal;
} else {
hours = diff.oldVal[Object.keys(diff.oldVal)[0]];
}
const ret = {
multiVal: false,
employeeid: diff.path[0], // Always True
mod_lbr_ty,
rate,
hours
};
return ret;
}
function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 };
const employeeHash = {}; // employeeid => Cieca labor type => rate => hours. Contains how many hours each person should be paid.
const employeeHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
const laborTypeFilter = Array.isArray(filterToLbrTypes) ? filterToLbrTypes : null;
job.joblines
.filter((jobline) => {
if (!filterToLbrTypes) return true;
else {
return (
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
);
if (!laborTypeFilter) {
return true;
}
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
})
.forEach((jobline) => {
if (jobline.convertedtolbr) {
// Line has been converte to labor. Temporarily re-assign the hours.
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty;
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs;
const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
const laborHours = roundHours(
toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
);
if (laborHours === 0) {
return;
}
if (jobline.mod_lb_hrs != 0) {
//Check if the line is assigned. If not, keep track of it as an unassigned line by type.
if (jobline.assigned_team === null) {
assignmentHash.unassigned = assignmentHash.unassigned + jobline.mod_lb_hrs;
} else {
//Line is assigned.
if (!assignmentHash[jobline.assigned_team]) {
assignmentHash[jobline.assigned_team] = 0;
}
assignmentHash[jobline.assigned_team] = assignmentHash[jobline.assigned_team] + jobline.mod_lb_hrs;
//Create the assignment breakdown.
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
if (jobline.assigned_team === null) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
theTeam.employee_team_members.forEach((tm) => {
//Figure out how many hours they are owed at this line, and at what rate.
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
if (!employeeHash[tm.employee.id]) {
employeeHash[tm.employee.id] = {};
}
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty]) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty] = {};
}
if (!employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]]) {
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] = 0;
}
if (!theTeam) {
assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100;
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] =
employeeHash[tm.employee.id][jobline.mod_lbr_ty][tm.labor_rates[jobline.mod_lbr_ty]] + hoursOwed;
});
assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
theTeam.employee_team_members.forEach((teamMember) => {
const employeeId = teamMember.employee.id;
const { effectiveRate, payoutContext } = BuildPayoutDetails(job, teamMember, laborType);
if (!employeeHash[employeeId]) {
employeeHash[employeeId] = {};
}
}
if (!employeeHash[employeeId][laborType]) {
employeeHash[employeeId][laborType] = {
hours: 0,
rate: effectiveRate,
payoutContext
};
}
const hoursOwed = roundHours((toNumber(teamMember.percentage) * laborHours) / 100);
employeeHash[employeeId][laborType].hours = roundHours(employeeHash[employeeId][laborType].hours + hoursOwed);
employeeHash[employeeId][laborType].rate = effectiveRate;
employeeHash[employeeId][laborType].payoutContext = payoutContext;
});
});
return { assignmentHash, employeeHash };
}
function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours.
//Calculate how much each employee has been paid so far.
const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
job.timetickets.forEach((ticket) => {
if (!ticket?.employeeid || !ticket?.ciecacode) {
return;
}
if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) {
ticketHash[ticket.employeeid][ticket.ciecacode] = {};
ticketHash[ticket.employeeid][ticket.ciecacode] = {
hours: 0,
rate: roundCurrency(ticket.rate),
payoutContext: ticket.payout_context || null
};
}
if (!ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate]) {
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] = 0;
ticketHash[ticket.employeeid][ticket.ciecacode].hours = roundHours(
ticketHash[ticket.employeeid][ticket.ciecacode].hours + toNumber(ticket.productivehrs)
);
if (ticket.rate !== null && ticket.rate !== undefined) {
ticketHash[ticket.employeeid][ticket.ciecacode].rate = roundCurrency(ticket.rate);
}
if (ticket.payout_context) {
ticketHash[ticket.employeeid][ticket.ciecacode].payoutContext = ticket.payout_context;
}
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] =
ticketHash[ticket.employeeid][ticket.ciecacode][ticket.rate] + ticket.productivehrs;
});
return ticketHash;
}
exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
exports.RoundPayrollHours = roundHours;

File diff suppressed because it is too large Load Diff