Merged in feature/IO-3587-Commision-Cut-clean (pull request #3138)

Feature/IO-3587 Commision Cut clean
This commit is contained in:
Dave Richer
2026-03-17 15:20:59 +00:00
38 changed files with 3393 additions and 2257 deletions

940
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,43 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ShopInfoTaskPresets); 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 }) { export function ShopInfoTaskPresets({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,8 +76,21 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}> <LayoutFormRow header={t("bodyshop.labels.md_tasks_presets")}>
<Form.List name={["md_tasks_presets", "presets"]}> <Form.List
{(fields, { add, remove, move }) => { 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 ( return (
<div> <div>
{fields.map((field, index) => ( {fields.map((field, index) => (
@@ -189,6 +239,7 @@ export function ShopInfoTaskPresets({ bodyshop }) {
</LayoutFormRow> </LayoutFormRow>
</Form.Item> </Form.Item>
))} ))}
<Form.ErrorList errors={errors} />
<Form.Item> <Form.Item>
<Button <Button
type="dashed" type="dashed"

View File

@@ -1,9 +1,23 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react"; import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Form, Input, InputNumber, 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 querystring from "query-string";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
@@ -26,57 +40,190 @@ import { useNotification } from "../../contexts/Notifications/notificationContex
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
const TEAM_MEMBER_PRIMARY_FIELD_COLS = {
employee: { xs: 24, lg: 13, xxl: 14 },
allocation: { xs: 24, sm: 12, lg: 4, xxl: 4 },
payoutMethod: { xs: 24, sm: 12, lg: 7, xxl: 6 }
};
const TEAM_MEMBER_RATE_FIELD_COLS = { xs: 24, sm: 12, md: 8, lg: 6, xxl: 4 };
const normalizeTeamMember = (teamMember = {}) => ({
...teamMember,
payout_method: teamMember.payout_method || "hourly",
labor_rates: teamMember.labor_rates || {},
commission_rates: teamMember.commission_rates || {}
}); });
const normalizeEmployeeTeam = (employeeTeam = {}) => ({
...employeeTeam,
employee_team_members: (employeeTeam.employee_team_members || []).map(normalizeTeamMember)
});
const getSplitTotal = (teamMembers = []) =>
teamMembers.reduce((sum, member) => sum + Number(member?.percentage || 0), 0);
const hasExactSplitTotal = (teamMembers = []) => Math.abs(getSplitTotal(teamMembers) - 100) < 0.00001;
const getPayoutMethodTagColor = (payoutMethod) => (payoutMethod === "commission" ? "gold" : "blue");
const getEmployeeDisplayName = (employees = [], employeeId) => {
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 }) { export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const history = useNavigate(); const history = useNavigate();
const search = querystring.parse(useLocation().search); const search = querystring.parse(useLocation().search);
const notification = useNotification(); 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 }, variables: { id: search.employeeTeamId },
skip: !search.employeeTeamId || search.employeeTeamId === "new", skip: !search.employeeTeamId || isNewTeam,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only" nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
}); });
useEffect(() => { useEffect(() => {
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk); if (!search.employeeTeamId) return;
else {
if (isNewTeam) {
form.resetFields(); 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 [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_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 = employee_team_members.map((teamMember) => {
const nextTeamMember = normalizeTeamMember({ ...teamMember });
delete nextTeamMember.__typename;
return nextTeamMember;
});
if (normalizedTeamMembers.length === 0) {
notification.error({
title: t("employee_teams.errors.minimum_one_member")
});
return;
}
const employeeIds = normalizedTeamMembers.map((teamMember) => teamMember.employeeid).filter(Boolean);
const duplicateEmployeeIds = employeeIds.filter((employeeId, index) => employeeIds.indexOf(employeeId) !== index);
if (duplicateEmployeeIds.length > 0) {
notification.error({
title: t("employee_teams.errors.duplicate_member")
});
return;
}
if (!hasExactSplitTotal(normalizedTeamMembers)) {
notification.error({
title: t("employee_teams.errors.allocation_total_exact")
});
return;
}
const handleFinish = async ({ employee_team_members, ...values }) => {
if (search.employeeTeamId && search.employeeTeamId !== "new") { if (search.employeeTeamId && search.employeeTeamId !== "new") {
//Update a record.
logImEXEvent("shop_employee_update"); logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({ const result = await updateEmployeeTeam({
variables: { variables: {
employeeTeamId: search.employeeTeamId, employeeTeamId: search.employeeTeamId,
employeeTeam: values, employeeTeam: values,
teamMemberUpdates: employee_team_members teamMemberUpdates: normalizedTeamMembers
.filter((e) => e.id) .filter((teamMember) => teamMember.id)
.map((e) => { .map((teamMember) => ({
delete e.__typename; where: { id: { _eq: teamMember.id } },
return { where: { id: { _eq: e.id } }, _set: e }; _set: teamMember
}), })),
teamMemberInserts: employee_team_members teamMemberInserts: normalizedTeamMembers
.filter((e) => e.id === null || e.id === undefined) .filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })), .map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter( teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
(e) => !employee_team_members.find((etm) => etm.id === e.id) .filter(
) (teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id)
)
.map((teamMember) => teamMember.id)
} }
}); });
if (!result.errors) { if (!result.errors) {
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
@@ -89,20 +236,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
}); });
} }
} else { } else {
//New record, insert it.
logImEXEvent("shop_employee_insert"); logImEXEvent("shop_employee_insert");
insertEmployeeTeam({ insertEmployeeTeam({
variables: { variables: {
employeeTeam: { employeeTeam: {
...values, ...values,
employee_team_members: { data: employee_team_members }, employee_team_members: { data: normalizedTeamMembers },
bodyshopid: bodyshop.id bodyshopid: bodyshop.id
} }
}, },
refetchQueries: ["QUERY_TEAMS"] refetchQueries: ["QUERY_TEAMS"]
}).then((r) => { }).then((response) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id; search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) }); history({ search: querystring.stringify(search) });
notification.success({ notification.success({
title: t("employees.successes.save") title: t("employees.successes.save")
@@ -116,288 +262,196 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
return ( return (
<Card <Card
title={teamCardTitle}
extra={ extra={
<Button type="primary" onClick={() => form.submit()}> <Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating}>
{t("general.actions.save")} {t("general.actions.save")}
</Button> </Button>
} }
> >
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}> {isTeamHydrating ? (
<LayoutFormRow> <Skeleton active title={false} paragraph={{ rows: 12 }} />
<Form.Item ) : (
name="name" <Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
label={t("employee_teams.fields.name")} <LayoutFormRow>
rules={[ <Form.Item
{ name="name"
required: true label={t("employee_teams.fields.name")}
//message: t("general.validation.required"), rules={[
} {
]} required: true
> }
<Input /> ]}
</Form.Item> >
<Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked"> <Input />
<Switch /> </Form.Item>
</Form.Item> <Form.Item label={t("employee_teams.fields.active")} name="active" valuePropName="checked">
<Form.Item <Switch />
label={t("employee_teams.fields.max_load")} </Form.Item>
name="max_load" <Form.Item
rules={[ label={t("employee_teams.fields.max_load")}
{ name="max_load"
required: true rules={[
//message: t("general.validation.required"), {
} required: true
]} }
> ]}
<InputNumber min={0} precision={1} /> >
</Form.Item> <InputNumber min={0} precision={1} />
</LayoutFormRow> </Form.Item>
<Form.List name={["employee_team_members"]}> </LayoutFormRow>
{(fields, { add, remove, move }) => { <Form.List name={["employee_team_members"]}>
return ( {(fields, { add, remove, move }) => {
<div> return (
{fields.map((field, index) => ( <div>
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}> {fields.map((field, index) => {
<Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden> const teamMember = normalizeTeamMember(teamMembers[field.name]);
<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>
<Form.Item return (
label={t("joblines.fields.lbr_types.LAF")} <Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
key={`${index}`} <Form.Item label={t("employees.fields.id")} key={`${index}`} name={[field.name, "id"]} hidden>
name={[field.name, "labor_rates", "LAF"]} <Input type="hidden" />
rules={[ </Form.Item>
{ <LayoutFormRow
required: true grow
//message: t("general.validation.required"), 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>
} }
]} >
> <div>
<CurrencyInput /> <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>
<Form.Item );
label={t("joblines.fields.lbr_types.LAG")} })}
key={`${index}`} <Form.Item>
name={[field.name, "labor_rates", "LAG"]} <Button
rules={[ type="dashed"
{ onClick={() => {
required: true add({
//message: t("general.validation.required"), percentage: 0,
} payout_method: "hourly",
]} labor_rates: {},
> commission_rates: {}
<CurrencyInput /> });
</Form.Item> }}
<Form.Item style={{ width: "100%" }}
label={t("joblines.fields.lbr_types.LAM")} >
key={`${index}`} {t("employee_teams.actions.newmember")}
name={[field.name, "labor_rates", "LAM"]} </Button>
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> </Form.Item>
))} <Form.Item noStyle shouldUpdate>
<Form.Item> {() => {
<Button const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
type="dashed" const splitTotal = getSplitTotal(teamMembers);
onClick={() => {
add(); return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}} }}
style={{ width: "100%" }} </Form.Item>
> </div>
{t("employee_teams.actions.newmember")} );
</Button> }}
</Form.Item> </Form.List>
</div> </Form>
); )}
}}
</Form.List>
</Form>
</Card> </Card>
); );
} }

View File

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

View File

@@ -25,6 +25,22 @@ const mapDispatchToProps = (dispatch) => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer); export default connect(mapStateToProps, mapDispatchToProps)(TimeTickeTaskModalContainer);
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const getPreviewPayoutAmount = (ticket) => {
const productiveHours = toFiniteNumber(ticket?.productivehrs);
const rate = toFiniteNumber(ticket?.rate);
if (productiveHours === null || rate === null) {
return null;
}
return productiveHours * rate;
};
export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) { export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicketTasksModal, toggleModalVisible }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const { context, open, actions } = timeTicketTasksModal; const { context, open, actions } = timeTicketTasksModal;
@@ -90,7 +106,12 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
if (actions?.refetch) actions.refetch(); if (actions?.refetch) actions.refetch();
toggleModalVisible(); toggleModalVisible();
} else if (handleFinish === false) { } else if (handleFinish === false) {
form.setFieldsValue({ timetickets: data.ticketsToInsert }); form.setFieldsValue({
timetickets: (data.ticketsToInsert || []).map((ticket) => ({
...ticket,
payoutamount: getPreviewPayoutAmount(ticket)
}))
});
setUnassignedHours(data.unassignedHours); setUnassignedHours(data.unassignedHours);
} else { } else {
notification.error({ notification.error({
@@ -101,7 +122,9 @@ export function TimeTickeTaskModalContainer({ currentUser, technician, timeTicke
} }
} catch (error) { } catch (error) {
notification.error({ notification.error({
title: t("timetickets.errors.creating", { message: error.message }) title: t("timetickets.errors.creating", {
message: error.response?.data?.error || error.message
})
}); });
} finally { } finally {
setLoading(false); setLoading(false);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "Error creating default view.", "creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique", "duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.", "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": { "fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}", "ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1175,12 +1176,28 @@
"new": "New Team", "new": "New Team",
"newmember": "New Team Member" "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": { "fields": {
"active": "Active", "active": "Active",
"allocation": "Allocation",
"allocation_percentage": "Allocation %",
"employeeid": "Employee", "employeeid": "Employee",
"max_load": "Max Load", "max_load": "Max Load",
"name": "Team Name", "name": "Team Name",
"payout_method": "Payout Method",
"percentage": "Percent" "percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
"commission": "Commission",
"commission_percentage": "Commission %",
"hourly": "Hourly"
} }
}, },
"employees": { "employees": {
@@ -3594,6 +3611,7 @@
}, },
"fields": { "fields": {
"actualhrs": "Actual Hours", "actualhrs": "Actual Hours",
"amount": "Amount",
"ciecacode": "CIECA Code", "ciecacode": "CIECA Code",
"clockhours": "Clock Hours", "clockhours": "Clock Hours",
"clockoff": "Clock Off", "clockoff": "Clock Off",
@@ -3608,7 +3626,10 @@
"employee_team": "Employee Team", "employee_team": "Employee Team",
"flat_rate": "Flat Rate?", "flat_rate": "Flat Rate?",
"memo": "Memo", "memo": "Memo",
"pay": "Pay",
"payout_method": "Payout Method",
"productivehrs": "Productive Hours", "productivehrs": "Productive Hours",
"rate": "Rate",
"ro_number": "Job to Post Against", "ro_number": "Job to Post Against",
"task_name": "Task" "task_name": "Task"
}, },
@@ -3627,6 +3648,10 @@
"lunch": "Lunch", "lunch": "Lunch",
"new": "New Time Ticket", "new": "New Time Ticket",
"payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.", "payrollclaimedtasks": "These time tickets will be automatically entered to the system as a part of claiming this task. These numbers are calculated using the jobs assigned lines. If lines are unassigned, they will be excluded from created tickets.",
"payout_methods": {
"commission": "Commission",
"hourly": "Hourly"
},
"pmbreak": "PM Break", "pmbreak": "PM Break",
"pmshift": "PM Shift", "pmshift": "PM Shift",
"shift": "Shift", "shift": "Shift",

View File

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

View File

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

View File

@@ -2156,10 +2156,12 @@
- active: - active:
_eq: true _eq: true
columns: columns:
- commission_rates
- created_at - created_at
- employeeid - employeeid
- id - id
- labor_rates - labor_rates
- payout_method
- percentage - percentage
- teamid - teamid
- updated_at - updated_at
@@ -2167,10 +2169,12 @@
- role: user - role: user
permission: permission:
columns: columns:
- commission_rates
- created_at - created_at
- employeeid - employeeid
- id - id
- labor_rates - labor_rates
- payout_method
- percentage - percentage
- teamid - teamid
- updated_at - updated_at
@@ -2188,10 +2192,12 @@
- role: user - role: user
permission: permission:
columns: columns:
- commission_rates
- created_at - created_at
- employeeid - employeeid
- id - id
- labor_rates - labor_rates
- payout_method
- percentage - percentage
- teamid - teamid
- updated_at - updated_at
@@ -6506,6 +6512,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- task_name - task_name
@@ -6531,6 +6538,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- task_name - task_name
@@ -6565,6 +6573,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- task_name - task_name
@@ -6748,6 +6757,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- updated_at - updated_at
@@ -6768,6 +6778,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- updated_at - updated_at
@@ -6798,6 +6809,7 @@
- id - id
- jobid - jobid
- memo - memo
- payout_context
- productivehrs - productivehrs
- rate - rate
- updated_at - 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" "job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-cloudwatch-logs": "^3.997.0", "@aws-sdk/client-cloudwatch-logs": "^3.1010.0",
"@aws-sdk/client-elasticache": "^3.997.0", "@aws-sdk/client-elasticache": "^3.1010.0",
"@aws-sdk/client-s3": "^3.997.0", "@aws-sdk/client-s3": "^3.1010.0",
"@aws-sdk/client-secrets-manager": "^3.997.0", "@aws-sdk/client-secrets-manager": "^3.1010.0",
"@aws-sdk/client-ses": "^3.997.0", "@aws-sdk/client-ses": "^3.1010.0",
"@aws-sdk/client-sqs": "^3.997.0", "@aws-sdk/client-sqs": "^3.1010.0",
"@aws-sdk/client-textract": "^3.997.0", "@aws-sdk/client-textract": "^3.1010.0",
"@aws-sdk/credential-provider-node": "^3.972.12", "@aws-sdk/credential-provider-node": "^3.972.21",
"@aws-sdk/lib-storage": "^3.997.0", "@aws-sdk/lib-storage": "^3.1010.0",
"@aws-sdk/s3-request-presigner": "^3.997.0", "@aws-sdk/s3-request-presigner": "^3.1010.0",
"@opensearch-project/opensearch": "^2.13.0", "@opensearch-project/opensearch": "^2.13.0",
"@socket.io/admin-ui": "^0.5.1", "@socket.io/admin-ui": "^0.5.1",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"aws4": "^1.13.2", "aws4": "^1.13.2",
"axios": "^1.13.5", "axios": "^1.13.6",
"axios-curlirize": "^2.0.0", "axios-curlirize": "^2.0.0",
"better-queue": "^3.8.12", "better-queue": "^3.8.12",
"bullmq": "^5.70.1", "bullmq": "^5.71.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cloudinary": "^2.9.0", "cloudinary": "^2.9.0",
"compression": "^1.8.1", "compression": "^1.8.1",
@@ -46,20 +46,20 @@
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^4.21.1", "express": "^4.21.1",
"fast-xml-parser": "^5.4.1", "fast-xml-parser": "^5.5.6",
"firebase-admin": "^13.6.1", "firebase-admin": "^13.7.0",
"fuse.js": "^7.1.0", "fuse.js": "^7.1.0",
"graphql": "^16.13.0", "graphql": "^16.13.1",
"graphql-request": "^6.1.0", "graphql-request": "^6.1.0",
"intuit-oauth": "^4.2.2", "intuit-oauth": "^4.2.2",
"ioredis": "^5.9.3", "ioredis": "^5.10.0",
"json-2-csv": "^5.5.10", "json-2-csv": "^5.5.10",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"juice": "^11.1.1", "juice": "^11.1.1",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"moment": "^2.30.1", "moment": "^2.30.1",
"moment-timezone": "^0.6.0", "moment-timezone": "^0.6.0",
"multer": "^2.0.2", "multer": "^2.1.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"node-persist": "^4.0.4", "node-persist": "^4.0.4",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
@@ -69,15 +69,15 @@
"recursive-diff": "^1.0.9", "recursive-diff": "^1.0.9",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"skia-canvas": "^3.0.8", "skia-canvas": "^3.0.8",
"soap": "^1.7.1", "soap": "^1.8.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-adapter": "^2.5.6", "socket.io-adapter": "^2.5.6",
"ssh2-sftp-client": "^11.0.0", "ssh2-sftp-client": "^11.0.0",
"twilio": "^5.12.2", "twilio": "^5.13.0",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"winston": "^3.19.0", "winston": "^3.19.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml-formatter": "^3.6.7", "xml-formatter": "^3.7.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^4.0.3", "xmlbuilder2": "^4.0.3",
"yazl": "^3.3.1" "yazl": "^3.3.1"
@@ -86,11 +86,11 @@
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"globals": "^17.3.0", "globals": "^17.4.0",
"mock-require": "^3.0.3", "mock-require": "^3.0.3",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"supertest": "^7.2.2", "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 percentage
labor_rates labor_rates
payout_method
commission_rates
} }
} }
} }
@@ -2473,6 +2475,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs productivehrs
actualhrs actualhrs
ciecacode ciecacode
payout_context
} }
lbr_adjustments lbr_adjustments
ro_number ro_number
@@ -2564,6 +2567,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
} }
percentage percentage
labor_rates labor_rates
payout_method
commission_rates
} }
} }
} }
@@ -2574,6 +2579,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs productivehrs
actualhrs actualhrs
ciecacode ciecacode
payout_context
} }
lbr_adjustments lbr_adjustments
ro_number ro_number

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,9 @@
const Dinero = require("dinero.js");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob, CalculateTicketsHoursForJob } = require("./pay-all"); 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) { 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); logger.log("job-payroll-calculate-labor", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken; const BearerToken = req.BearerToken;
@@ -41,23 +30,19 @@ exports.calculatelabor = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => { Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level. //At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => { Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level const expected = employeeHash[employeeIdKey][laborTypeKey];
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => { const claimed = ticketHash?.[employeeIdKey]?.[laborTypeKey];
//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];
}
totals.push({ if (claimed) {
employeeid: employeeIdKey, delete ticketHash[employeeIdKey][laborTypeKey];
rate: rateKey, }
mod_lbr_ty: laborTypeKey,
expectedHours, totals.push({
claimedHours: claimedHours || 0 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) => { Object.keys(ticketHash).forEach((employeeIdKey) => {
//At the employee level. //At the employee level.
Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => { Object.keys(ticketHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level const claimed = ticketHash[employeeIdKey][laborTypeKey];
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];
}
totals.push({ totals.push({
employeeid: employeeIdKey, employeeid: employeeIdKey,
rate: rateKey, rate: claimed.rate,
mod_lbr_ty: laborTypeKey, mod_lbr_ty: laborTypeKey,
expectedHours, expectedHours: 0,
claimedHours: claimedHours || 0 claimedHours: claimed.hours || 0
});
}); });
}); });
}); });
@@ -101,6 +77,6 @@ exports.calculatelabor = async function (req, res) {
jobid: jobid, jobid: jobid,
error 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 queries = require("../graphql-client/queries");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const { CalculateExpectedHoursForJob } = require("./pay-all"); const { CalculateExpectedHoursForJob, RoundPayrollHours } = require("./pay-all");
const moment = require("moment"); const moment = require("moment");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA"; const normalizePercent = (value) => Math.round((Number(value || 0) + Number.EPSILON) * 10000) / 10000;
Dinero.globalRoundingMode = "HALF_EVEN";
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) { exports.claimtask = async function (req, res) {
const { jobid, task, calculateOnly, employee } = req.body; const { jobid, task, calculateOnly, employee } = req.body;
@@ -21,12 +52,25 @@ exports.claimtask = async function (req, res) {
id: jobid 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) { if (!theTaskPreset) {
res.status(400).json({ success: false, error: "Provided task preset not found." }); res.status(400).json({ success: false, error: "Provided task preset not found." });
return; 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. //Get all of the assignments that are filtered.
const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype); const { assignmentHash, employeeHash } = CalculateExpectedHoursForJob(job, theTaskPreset.hourstype);
const ticketsToInsert = []; const ticketsToInsert = [];
@@ -35,32 +79,37 @@ exports.claimtask = async function (req, res) {
Object.keys(employeeHash).forEach((employeeIdKey) => { Object.keys(employeeHash).forEach((employeeIdKey) => {
//At the employee level. //At the employee level.
Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => { Object.keys(employeeHash[employeeIdKey]).forEach((laborTypeKey) => {
//At the labor level const expected = employeeHash[employeeIdKey][laborTypeKey];
Object.keys(employeeHash[employeeIdKey][laborTypeKey]).forEach((rateKey) => { const expectedHours = RoundPayrollHours(expected.hours * (theTaskPreset.percent / 100));
//At the rate level.
const expectedHours = employeeHash[employeeIdKey][laborTypeKey][rateKey] * (theTaskPreset.percent / 100);
ticketsToInsert.push({ ticketsToInsert.push({
task_name: task, task_name: task,
jobid: job.id, jobid: job.id,
bodyshopid: job.bodyshop.id, bodyshopid: job.bodyshop.id,
employeeid: employeeIdKey, employeeid: employeeIdKey,
productivehrs: expectedHours, productivehrs: expectedHours,
rate: rateKey, rate: expected.rate,
ciecacode: laborTypeKey, ciecacode: laborTypeKey,
flat_rate: true, flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborTypeKey], created_by: employee?.name || req.user.email,
memo: `*Flagged Task* ${theTaskPreset.memo}` 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) { if (!calculateOnly) {
//Insert the time ticekts if we're not just calculating them. //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) timetickets: ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0)
}); });
const updateResult = await client.request(queries.UPDATE_JOB, { await client.request(queries.UPDATE_JOB, {
jobId: job.id, jobId: job.id,
job: { job: {
status: theTaskPreset.nextstatus, status: theTaskPreset.nextstatus,
@@ -82,6 +131,6 @@ exports.claimtask = async function (req, res) {
jobid: jobid, jobid: jobid,
error 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 Dinero = require("dinero.js");
const queries = require("../graphql-client/queries"); const queries = require("../graphql-client/queries");
const rdiff = require("recursive-diff");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
// Dinero.defaultCurrency = "USD";
// Dinero.globalLocale = "en-CA";
Dinero.globalRoundingMode = "HALF_EVEN"; 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) { 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); logger.log("job-payroll-pay-all", "DEBUG", req.user.email, jobid, null);
const BearerToken = req.BearerToken; const BearerToken = req.BearerToken;
@@ -22,253 +203,183 @@ exports.payall = async function (req, res) {
id: jobid id: jobid
}); });
//iterate over each ticket, building a hash of team -> employee to calculate total assigned hours.
const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job); const { employeeHash, assignmentHash } = CalculateExpectedHoursForJob(job);
const ticketHash = CalculateTicketsHoursForJob(job); const ticketHash = CalculateTicketsHoursForJob(job);
if (assignmentHash.unassigned > 0) { if (assignmentHash.unassigned > 0) {
res.json({ success: false, error: "Not all hours have been assigned." }); res.json({ success: false, error: "Not all hours have been assigned." });
return; 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 ticketsToInsert = [];
const employeeIds = getAllKeys(employeeHash, ticketHash);
recursiveDiff.forEach((diff) => { employeeIds.forEach((employeeId) => {
//Every iteration is what we would need to insert into the time ticket hash const expectedByLabor = employeeHash[employeeId] || {};
//so that it would match the employee hash exactly. const claimedByLabor = ticketHash[employeeId] || {};
const path = diffParser(diff);
if (diff.op === "add") { getAllKeys(expectedByLabor, claimedByLabor).forEach((laborType) => {
// console.log(Object.keys(diff.val)); const expected = expectedByLabor[laborType];
if (typeof diff.val === "object" && Object.keys(diff.val).length > 1) { const claimed = claimedByLabor[laborType];
//Multiple values to add. const deltaHours = roundHours((expected?.hours || 0) - (claimed?.hours || 0));
Object.keys(diff.val).forEach((key) => {
// console.log("Hours", diff.val[key][Object.keys(diff.val[key])[0]]); if (deltaHours === 0) {
// console.log("Rate", Object.keys(diff.val[key])[0]); return;
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})`
});
} }
} else if (diff.op === "update") {
//An old ticket amount isn't sufficient const effectiveRate = roundCurrency(expected?.rate ?? claimed?.rate);
//We can't modify the existing ticket, it might already be committed. So let's add a new one instead. 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({ ticketsToInsert.push({
task_name: "Pay All", task_name: "Pay All",
jobid: job.id, jobid: job.id,
bodyshopid: job.bodyshop.id, bodyshopid: job.bodyshop.id,
employeeid: path.employeeid, employeeid: employeeId,
productivehrs: diff.val - diff.oldVal, productivehrs: deltaHours,
rate: path.rate, rate: effectiveRate,
ciecacode: path.mod_lbr_ty, ciecacode: laborType,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[laborType],
flat_rate: true, flat_rate: true,
cost_center: job.bodyshop.md_responsibility_centers.defaults.costs[path.mod_lbr_ty], created_by: req.user.email,
memo: `Adjust flagged hours per assignment. (${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, { const filteredTickets = ticketsToInsert.filter((ticket) => ticket.productivehrs !== 0);
timetickets: 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) { } catch (error) {
logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, { logger.log("job-payroll-labor-totals-error", "ERROR", req.user.email, jobid, {
jobid: jobid, jobid,
error: JSON.stringify(error) error: JSON.stringify(error)
}); });
res.status(400).json({ error: error.message }); 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) { function CalculateExpectedHoursForJob(job, filterToLbrTypes) {
const assignmentHash = { unassigned: 0 }; 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 job.joblines
.filter((jobline) => { .filter((jobline) => {
if (!filterToLbrTypes) return true; if (!laborTypeFilter) {
else { return true;
return (
filterToLbrTypes.includes(jobline.mod_lbr_ty) ||
(jobline.convertedtolbr && filterToLbrTypes.includes(jobline.convertedtolbr_data.mod_lbr_ty))
);
} }
const convertedLaborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty : null;
return laborTypeFilter.includes(jobline.mod_lbr_ty) || (convertedLaborType && laborTypeFilter.includes(convertedLaborType));
}) })
.forEach((jobline) => { .forEach((jobline) => {
if (jobline.convertedtolbr) { const laborType = jobline.convertedtolbr ? jobline.convertedtolbr_data?.mod_lbr_ty || jobline.mod_lbr_ty : jobline.mod_lbr_ty;
// Line has been converte to labor. Temporarily re-assign the hours. const laborHours = roundHours(
jobline.mod_lbr_ty = jobline.convertedtolbr_data.mod_lbr_ty; toNumber(jobline.mod_lb_hrs) + (jobline.convertedtolbr ? toNumber(jobline.convertedtolbr_data?.mod_lb_hrs) : 0)
jobline.mod_lb_hrs += jobline.convertedtolbr_data.mod_lb_hrs; );
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. if (jobline.assigned_team === null) {
const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team); assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
return;
}
theTeam.employee_team_members.forEach((tm) => { const theTeam = job.bodyshop.employee_teams.find((team) => team.id === jobline.assigned_team);
//Figure out how many hours they are owed at this line, and at what rate.
if (!employeeHash[tm.employee.id]) { if (!theTeam) {
employeeHash[tm.employee.id] = {}; assignmentHash.unassigned = roundHours(assignmentHash.unassigned + laborHours);
} return;
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;
}
const hoursOwed = (tm.percentage * jobline.mod_lb_hrs) / 100; assignmentHash[jobline.assigned_team] = roundHours((assignmentHash[jobline.assigned_team] || 0) + laborHours);
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; 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 }; return { assignmentHash, employeeHash };
} }
function CalculateTicketsHoursForJob(job) { function CalculateTicketsHoursForJob(job) {
const ticketHash = {}; // employeeid => Cieca labor type => rate => hours. const ticketHash = {}; // employeeid => Cieca labor type => { hours, rate, payoutContext }
//Calculate how much each employee has been paid so far.
job.timetickets.forEach((ticket) => { job.timetickets.forEach((ticket) => {
if (!ticket?.employeeid || !ticket?.ciecacode) {
return;
}
if (!ticketHash[ticket.employeeid]) { if (!ticketHash[ticket.employeeid]) {
ticketHash[ticket.employeeid] = {}; ticketHash[ticket.employeeid] = {};
} }
if (!ticketHash[ticket.employeeid][ticket.ciecacode]) { 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; return ticketHash;
} }
exports.BuildPayoutDetails = BuildPayoutDetails;
exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob; exports.CalculateExpectedHoursForJob = CalculateExpectedHoursForJob;
exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob; exports.CalculateTicketsHoursForJob = CalculateTicketsHoursForJob;
exports.RoundPayrollHours = roundHours;

View File

@@ -0,0 +1,465 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import mockRequire from "mock-require";
const logMock = vi.fn();
let payAllModule;
let claimTaskModule;
const buildBaseJob = (overrides = {}) => ({
id: "job-1",
completed_tasks: [],
rate_laa: 100,
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: []
},
joblines: [],
timetickets: [],
...overrides
});
const buildReqRes = ({ job, body = {}, userEmail = "payroll@example.com" }) => {
const client = {
setHeaders: vi.fn().mockReturnThis(),
request: vi.fn().mockResolvedValueOnce({ jobs_by_pk: job })
};
const req = {
body: {
jobid: job.id,
...body
},
user: {
email: userEmail
},
BearerToken: "Bearer test",
userGraphQLClient: client
};
const res = {
json: vi.fn(),
status: vi.fn().mockReturnThis()
};
return { client, req, res };
};
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
mockRequire.stopAll();
mockRequire("../utils/logger", { log: logMock });
payAllModule = require("./pay-all");
claimTaskModule = require("./claim-task");
});
describe("payroll payout helpers", () => {
it("defaults team members to hourly payout when no payout method is stored", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 27.5
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(27.5);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "hourly",
payout_method: "hourly",
cut_percent_applied: null,
source_labor_rate: null,
source_labor_type: "LAA",
effective_rate: 27.5
})
);
});
it("calculates commission payout rates from the raw job labor sale rate", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{
rate_laa: 120
},
{
payout_method: "commission",
commission_rates: {
LAA: 35
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(42);
expect(payoutContext).toEqual(
expect.objectContaining({
payout_type: "cut",
payout_method: "commission",
cut_percent_applied: 35,
source_labor_rate: 120,
source_labor_type: "LAA",
effective_rate: 42
})
);
});
it("uses Dinero half-even rounding for stored hourly rates", () => {
const { effectiveRate, payoutContext } = payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {
LAA: 10.005
},
employee: {
id: "emp-1"
}
},
"LAA"
);
expect(effectiveRate).toBe(10);
expect(payoutContext.effective_rate).toBe(10);
});
it("throws a useful error when commission configuration is incomplete", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{
rate_laa: 100
},
{
payout_method: "commission",
commission_rates: {},
employee: {
first_name: "Jane",
last_name: "Doe"
}
},
"LAA"
)
).toThrow("Missing commission percent for Jane Doe on labor type LAA.");
});
it("throws a useful error when an hourly payout rate is missing", () => {
expect(() =>
payAllModule.BuildPayoutDetails(
{},
{
labor_rates: {},
employee: {
first_name: "John",
last_name: "Smith"
}
},
"LAB"
)
).toThrow("Missing hourly payout rate for John Smith on labor type LAB.");
});
});
describe("payroll routes", () => {
it("aggregates claimed hours across prior ticket rates and inserts the remaining delta at the current rate", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: []
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 100,
payout_method: "commission",
commission_rates: {
LAA: 40
},
labor_rates: {
LAA: 30
},
employee: {
id: "emp-1",
first_name: "Jane",
last_name: "Doe"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAA",
mod_lb_hrs: 10,
assigned_team: "team-1",
convertedtolbr: false
}
],
timetickets: [
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 2,
rate: 30,
payout_context: {
payout_method: "hourly"
}
},
{
employeeid: "emp-1",
ciecacode: "LAA",
productivehrs: 3,
rate: 35,
payout_context: {
payout_method: "commission"
}
}
]
});
const { client, req, res } = buildReqRes({ job });
client.request.mockResolvedValueOnce({ insert_timetickets: { affected_rows: 1 } });
await payAllModule.payall(req, res);
expect(client.request).toHaveBeenCalledTimes(2);
const insertedTickets = client.request.mock.calls[1][1].timetickets;
expect(insertedTickets).toHaveLength(1);
expect(insertedTickets[0]).toEqual(
expect.objectContaining({
task_name: "Pay All",
employeeid: "emp-1",
productivehrs: 5,
rate: 40,
ciecacode: "LAA",
cost_center: "Body",
created_by: "payroll@example.com"
})
);
expect(insertedTickets[0].payout_context).toEqual(
expect.objectContaining({
payout_method: "commission",
cut_percent_applied: 40,
source_labor_rate: 100,
generated_from: "payall",
task_name: "Pay All",
used_ticket_fallback: false
})
);
expect(res.json).toHaveBeenCalledWith(insertedTickets);
});
it("rejects duplicate claim-task submissions for completed presets", async () => {
const job = buildBaseJob({
completed_tasks: [{ name: "Disassembly" }],
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Disassembly",
hourstype: ["LAA"],
percent: 50,
nextstatus: "In Progress",
memo: "Flag disassembly"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Disassembly",
calculateOnly: false,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Provided task preset has already been completed for this job."
});
});
it("rejects claim-task when task presets over-allocate the same labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAA: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Body Prep",
hourstype: ["LAA"],
percent: 60,
nextstatus: "Prep",
memo: "Prep body work"
},
{
name: "Body Prime",
hourstype: ["LAA"],
percent: 50,
nextstatus: "Prime",
memo: "Prime body work"
}
]
},
employee_teams: []
}
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Body Prep",
calculateOnly: true,
employee: {
name: "Jane Doe",
employeeid: "emp-1"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Task preset percentages for labor type LAA total 110% and cannot exceed 100%."
});
});
it("rejects claim-task when an assigned team member is missing the hourly rate for the selected labor type", async () => {
const job = buildBaseJob({
bodyshop: {
id: "shop-1",
md_responsibility_centers: {
defaults: {
costs: {
LAB: "Body"
}
}
},
md_tasks_presets: {
presets: [
{
name: "Teardown",
hourstype: ["LAB"],
percent: 100,
nextstatus: "In Progress",
memo: "Teardown"
}
]
},
employee_teams: [
{
id: "team-1",
employee_team_members: [
{
percentage: 50,
labor_rates: {
LAB: 45
},
employee: {
id: "emp-1",
first_name: "Configured",
last_name: "Tech"
}
},
{
percentage: 50,
labor_rates: {},
employee: {
id: "emp-2",
first_name: "Missing",
last_name: "Rate"
}
}
]
}
]
},
joblines: [
{
mod_lbr_ty: "LAB",
mod_lb_hrs: 4.4,
assigned_team: "team-1",
convertedtolbr: false
}
]
});
const { client, req, res } = buildReqRes({
job,
body: {
task: "Teardown",
calculateOnly: true,
employee: {
name: "Dave",
email: "dave@rome.test"
}
}
});
await claimTaskModule.claimtask(req, res);
expect(client.request).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({
success: false,
error: "Missing hourly payout rate for Missing Rate on labor type LAB."
});
});
});