Compare commits

...

17 Commits

Author SHA1 Message Date
Dave
665f09d832 feature/IO-3614-March-2026-Tech-Debt - GraphQL-Request backend package bump 2026-03-16 11:13:15 -04:00
Dave
3d7f2961fd Tests + Packages + Vite 2026-03-16 11:02:48 -04:00
Dave Richer
af52c35013 Merged in feature/IO-2433-esignature (pull request #3133)
Feature/IO-2433 esignature
2026-03-14 01:17:41 +00:00
Dave Richer
36157d87bb Merged in feature/IO-3587-Commision-Cut (pull request #3132)
Feature/IO-3587 Commision Cut
2026-03-14 01:14:43 +00:00
Dave
722375fede feature/IO-3587-Commision-Cut - Remove some unrequired cleanup to reduce risk 2026-03-13 21:13:43 -04:00
Dave
339c19a041 Merge branch 'master-AIO' into feature/IO-3587-Commision-Cut 2026-03-13 21:05:36 -04:00
Dave Richer
b8570f3ae9 Merged in release/2026-03-13 (pull request #3130)
IO-3610 Export Log DMS Bug
2026-03-13 03:06:47 +00:00
Allan Carr
6ef56f97c0 IO-2433 Missing Translation
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2026-03-12 16:58:59 -07:00
Dave
dd633cea89 hotfix/2026-03-12 - Be more specific on CDK error passing, resolve circular dependency 2026-03-12 19:45:55 -04:00
Dave Richer
fb863c7979 Merged in release/2026-03-13 (pull request #3126)
IO-3585 saleClassValue fix
2026-03-12 20:34:21 +00:00
Dave Richer
8102fd5177 Merged in release/2026-03-13 (pull request #3120)
Release/2026 03 13
2026-03-12 16:39:36 +00:00
Dave
c7bb1a9c32 feature/IO-3587-Comission-Cut - Implement 2026-03-12 12:19:34 -04:00
Patrick Fic
97d8047a3d Update casing for esign route. 2026-03-05 15:56:13 -08:00
Patrick Fic
16220d0a27 Merge branch 'master-AIO' into feature/IO-2433-esignature 2026-03-04 15:01:25 -08:00
Patrick Fic
51fba24a3d IO-2433 Delete on cancel, improved styling. 2026-02-27 16:03:27 -08:00
Patrick Fic
52f43a600c IO-2433 Basic completion webhook, S3 upload, audit trail. 2026-02-27 15:44:23 -08:00
Patrick Fic
e25174ff97 IO-2433 Basic embedded authoring. 2026-02-27 13:15:10 -08:00
53 changed files with 5265 additions and 4522 deletions

3716
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -509,3 +509,10 @@
pointer-events: none !important;
}
}
.esignature-embed {
width: 100%;
height: 100%;
border-width: 0;
}

View File

@@ -0,0 +1,78 @@
import { EmbedUpdateDocumentV1 } from "@documenso/embed-react";
import { Modal } from "antd";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectEsignature } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({
esignatureModal: selectEsignature,
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("esignature"))
});
export function EsignatureModalContainer({ esignatureModal, toggleModalVisible, bodyshop }) {
const { t } = useTranslation();
const { open, context } = esignatureModal;
const { token, envelopeId, documentId, jobid } = context;
return (
<Modal
open={open}
title={t("jobs.labels.esignature")}
onOk={async () => {
try {
const distResult = await axios.post("/esign/distribute", {
documentId,
envelopeId,
jobid,
bodyshopid: bodyshop.id
});
console.log("Distribution result:", distResult);
toggleModalVisible();
} catch (error) {
console.error("Error distributing document:", error);
}
}}
onCancel={async () => {
try {
const cancelResult = await axios.post("/esign/delete", {
documentId,
envelopeId
});
console.log("Cancel result:", cancelResult);
toggleModalVisible();
} catch (error) {
console.error("Error cancelling document:", error);
}
}}
okButtonProps={{ title: "Distribute by Email" }}
width="90%"
destroyOnHidden
>
<div style={{ height: "600px", width: "100%" }}>
{token ? (
<EmbedUpdateDocumentV1
presignToken={token}
host="https://stg-app.documenso.com"
documentId={documentId}
className="esignature-embed"
onDocumentUpdated={(data) => {
console.log("Document updated:", data.documentId);
}}
/>
) : (
<div>No token...</div>
)}
</div>
</Modal>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(EsignatureModalContainer);

View File

@@ -11,7 +11,7 @@ const mapDispatchToProps = () => ({
});
const ReadOnlyFormItem = ({ bodyshop, value, type = "text" }) => {
if (!value) return null;
if (value === null || value === undefined || value === "") return null;
switch (type) {
case "employee": {
const emp = bodyshop.employees.find((e) => e.id === value);

View File

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

View File

@@ -1,4 +1,4 @@
import { MailOutlined, PrinterOutlined } from "@ant-design/icons";
import { MailOutlined, PrinterOutlined, SignatureFilled } from "@ant-design/icons";
import { Space, Spin } from "antd";
import { useState } from "react";
import { connect } from "react-redux";
@@ -10,6 +10,8 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
import LockWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import { HasFeatureAccess } from "./../feature-wrapper/feature-wrapper.component";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import axios from "axios";
import { setModalContext } from "../../redux/modals/modals.actions.js";
const mapStateToProps = createStructuredSelector({
printCenterModal: selectPrintCenter,
@@ -17,9 +19,25 @@ const mapStateToProps = createStructuredSelector({
technician: selectTechnician
});
const mapDispatchToProps = () => ({});
const mapDispatchToProps = (dispatch) => ({
setEsignatureContext: (context) =>
dispatch(
setModalContext({
context: context,
modal: "esignature"
})
)
});
export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop, disabled, technician }) {
export function PrintCenterItemComponent({
printCenterModal,
setEsignatureContext,
item,
id,
bodyshop,
disabled,
technician
}) {
const [loading, setLoading] = useState(false);
const { context } = printCenterModal;
const notification = useNotification();
@@ -39,6 +57,30 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
setLoading(false);
};
const esignatureGenerate = async () => {
setLoading(true);
try {
const {
data: { token, documentId, evnelopeId }
} = await axios.post("/esign/new", {
name: item.key,
jobid: id,
context,
bodyshop,
templateObject: {
name: item.key,
variables: { id: id }
}
});
setEsignatureContext({ context: { token, documentId, evnelopeId, jobid: id } });
} catch (error) {
console.log(error);
} finally {
setLoading(false);
}
};
if (
disabled ||
(item.featureNameRestricted && !HasFeatureAccess({ featureName: item.featureNameRestricted, bodyshop }))
@@ -54,6 +96,7 @@ export function PrintCenterItemComponent({ printCenterModal, item, id, bodyshop,
<li>
<Space wrap>
{item.title}
<SignatureFilled onClick={esignatureGenerate} />
<PrinterOutlined onClick={renderToNewWindow} />
{!technician ? (
<MailOutlined

View File

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

View File

@@ -1,6 +1,6 @@
import { DeleteFilled } from "@ant-design/icons";
import { useMutation, useQuery } from "@apollo/client/react";
import { Button, Card, Form, Input, InputNumber, Space, Switch } from "antd";
import { Button, Card, Form, Input, InputNumber, Select, Space, Switch, Typography } from "antd";
import querystring from "query-string";
import { useEffect } from "react";
@@ -26,10 +26,32 @@ import { useNotification } from "../../contexts/Notifications/notificationContex
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
const mapDispatchToProps = () => ({});
const LABOR_TYPES = ["LAA", "LAB", "LAD", "LAE", "LAF", "LAG", "LAM", "LAR", "LAS", "LAU", "LA1", "LA2", "LA3", "LA4"];
const PAYOUT_METHOD_OPTIONS = [
{ labelKey: "employee_teams.options.hourly", value: "hourly" },
{ labelKey: "employee_teams.options.commission_percentage", value: "commission" }
];
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;
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
const { t } = useTranslation();
const [form] = Form.useForm();
@@ -45,38 +67,73 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
useEffect(() => {
if (data?.employee_teams_by_pk) form.setFieldsValue(data.employee_teams_by_pk);
else {
if (data?.employee_teams_by_pk) {
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
} else {
form.resetFields();
}
}, [form, data, search.employeeTeamId]);
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
const payoutMethodOptions = PAYOUT_METHOD_OPTIONS.map(({ labelKey, value }) => ({
label: t(labelKey),
value
}));
const 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") {
//Update a record.
logImEXEvent("shop_employee_update");
const result = await updateEmployeeTeam({
variables: {
employeeTeamId: search.employeeTeamId,
employeeTeam: values,
teamMemberUpdates: employee_team_members
.filter((e) => e.id)
.map((e) => {
delete e.__typename;
return { where: { id: { _eq: e.id } }, _set: e };
}),
teamMemberInserts: employee_team_members
.filter((e) => e.id === null || e.id === undefined)
.map((e) => ({ ...e, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members.filter(
(e) => !employee_team_members.find((etm) => etm.id === e.id)
)
teamMemberUpdates: normalizedTeamMembers
.filter((teamMember) => teamMember.id)
.map((teamMember) => ({
where: { id: { _eq: teamMember.id } },
_set: teamMember
})),
teamMemberInserts: normalizedTeamMembers
.filter((teamMember) => teamMember.id === null || teamMember.id === undefined)
.map((teamMember) => ({ ...teamMember, teamid: search.employeeTeamId })),
teamMemberDeletes: data.employee_teams_by_pk.employee_team_members
.filter((teamMember) => !normalizedTeamMembers.find((currentTeamMember) => currentTeamMember.id === teamMember.id))
.map((teamMember) => teamMember.id)
}
});
if (!result.errors) {
notification.success({
title: t("employees.successes.save")
@@ -89,20 +146,19 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
});
}
} else {
//New record, insert it.
logImEXEvent("shop_employee_insert");
insertEmployeeTeam({
variables: {
employeeTeam: {
...values,
employee_team_members: { data: employee_team_members },
employee_team_members: { data: normalizedTeamMembers },
bodyshopid: bodyshop.id
}
},
refetchQueries: ["QUERY_TEAMS"]
}).then((r) => {
search.employeeTeamId = r.data.insert_employee_teams_one.id;
}).then((response) => {
search.employeeTeamId = response.data.insert_employee_teams_one.id;
history({ search: querystring.stringify(search) });
notification.success({
title: t("employees.successes.save")
@@ -130,7 +186,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -145,7 +200,6 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
@@ -169,207 +223,61 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<EmployeeSearchSelectComponent options={bodyshop.employees} />
</Form.Item>
<Form.Item
label={t("employee_teams.fields.percentage")}
label={t("employee_teams.fields.allocation_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"]}
label={t("employee_teams.fields.payout_method")}
key={`${index}-payout-method`}
name={[field.name, "payout_method"]}
initialValue="hourly"
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 />
<Select options={payoutMethodOptions} />
</Form.Item>
<Form.Item noStyle dependencies={[["employee_team_members", field.name, "payout_method"]]}>
{() => {
const payoutMethod =
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) || "hourly";
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
<Form.Item
label={t("joblines.fields.lbr_types.LAF")}
key={`${index}`}
name={[field.name, "labor_rates", "LAF"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAG")}
key={`${index}`}
name={[field.name, "labor_rates", "LAG"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAM")}
key={`${index}`}
name={[field.name, "labor_rates", "LAM"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAR")}
key={`${index}`}
name={[field.name, "labor_rates", "LAR"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAS")}
key={`${index}`}
name={[field.name, "labor_rates", "LAS"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LAU")}
key={`${index}`}
name={[field.name, "labor_rates", "LAU"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA1")}
key={`${index}`}
name={[field.name, "labor_rates", "LA1"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA2")}
key={`${index}`}
name={[field.name, "labor_rates", "LA2"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA3")}
key={`${index}`}
name={[field.name, "labor_rates", "LA3"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
</Form.Item>
<Form.Item
label={t("joblines.fields.lbr_types.LA4")}
key={`${index}`}
name={[field.name, "labor_rates", "LA4"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<CurrencyInput />
return LABOR_TYPES.map((laborType) => (
<Form.Item
label={payoutMethod === "commission" ? `${t(`joblines.fields.lbr_types.${laborType}`)} %` : t(`joblines.fields.lbr_types.${laborType}`)}
key={`${index}-${fieldName}-${laborType}`}
name={[field.name, fieldName, laborType]}
rules={[
{
required: true
}
]}
>
{payoutMethod === "commission" ? (
<InputNumber min={0} max={100} precision={2} />
) : (
<CurrencyInput />
)}
</Form.Item>
));
}}
</Form.Item>
<Space align="center">
<DeleteFilled
@@ -386,13 +294,32 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
<Button
type="dashed"
onClick={() => {
add();
add({
percentage: 0,
payout_method: "hourly",
labor_rates: {},
commission_rates: {}
});
}}
style={{ width: "100%" }}
>
{t("employee_teams.actions.newmember")}
</Button>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const teamMembers = form.getFieldValue(["employee_team_members"]) || [];
const splitTotal = getSplitTotal(teamMembers);
return (
<Typography.Text type={hasExactSplitTotal(teamMembers) ? undefined : "danger"}>
{t("employee_teams.labels.allocation_total", {
total: splitTotal.toFixed(2)
})}
</Typography.Text>
);
}}
</Form.Item>
</div>
);
}}

View File

@@ -35,7 +35,15 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<JobSearchSelectComponent convertedOnly={true} notExported={true} />
</Form.Item>
<Space wrap>
<Form.Item name="task" label={t("timetickets.labels.task")}>
<Form.Item
name="task"
label={t("timetickets.labels.task")}
rules={[
{
required: true
}
]}
>
{loading ? (
<Spin />
) : (
@@ -93,6 +101,8 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<th>{t("timetickets.fields.cost_center")}</th>
<th>{t("timetickets.fields.ciecacode")}</th>
<th>{t("timetickets.fields.productivehrs")}</th>
<th>{t("timetickets.fields.rate")}</th>
<th>{t("timetickets.fields.amount")}</th>
</tr>
</thead>
<tbody>
@@ -118,6 +128,16 @@ export function TimeTicketTaskModalComponent({ bodyshop, form, loading, complete
<ReadOnlyFormItemComponent />
</Form.Item>
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import InstanceRenderManager from "../../utils/instanceRenderMgr.js";
import useAlertsNotifications from "../../hooks/useAlertsNotifications.jsx";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import { lazyDev } from "../../utils/lazyWithPreload.jsx";
import EsignatureModalContainer from "../../components/esignature-modal/esignature-modal.container.jsx";
const PrintCenterModalContainer = lazyDev(
() => import("../../components/print-center-modal/print-center-modal.container")
@@ -68,7 +69,9 @@ const FeatureRequestPage = lazyDev(() => import("../feature-request/feature-requ
const JobCostingModal = lazyDev(() => import("../../components/job-costing-modal/job-costing-modal.container"));
const ReportCenterModal = lazyDev(() => import("../../components/report-center-modal/report-center-modal.container"));
const BillEnterModalContainer = lazyDev(() => import("../../components/bill-enter-modal/bill-enter-modal.container"));
const TimeTicketModalContainer = lazyDev(() => import("../../components/time-ticket-modal/time-ticket-modal.container"));
const TimeTicketModalContainer = lazyDev(
() => import("../../components/time-ticket-modal/time-ticket-modal.container")
);
const TimeTicketModalTask = lazyDev(
() => import("../../components/time-ticket-task-modal/time-ticket-task-modal.container")
);
@@ -110,7 +113,9 @@ const TtApprovals = lazyDev(() => import("../tt-approvals/tt-approvals.page.cont
const MyTasksPage = lazyDev(() => import("../tasks/myTasksPageContainer.jsx"));
const AllTasksPage = lazyDev(() => import("../tasks/allTasksPageContainer.jsx"));
const TaskUpsertModalContainer = lazyDev(() => import("../../components/task-upsert-modal/task-upsert-modal.container"));
const TaskUpsertModalContainer = lazyDev(
() => import("../../components/task-upsert-modal/task-upsert-modal.container")
);
const { Content } = Layout;
const mapStateToProps = createStructuredSelector({
@@ -178,6 +183,7 @@ export function Manage({ conflict, bodyshop, partsManagementOnly, isDarkMode, cu
<TaskUpsertModalContainer />
<BreadCrumbs />
<BillEnterModalContainer />
<EsignatureModalContainer />
<JobCostingModal />
<ReportCenterModal />
<EmailOverlayContainer />

View File

@@ -27,7 +27,8 @@ const INITIAL_STATE = {
contractFinder: { ...baseModal },
inventoryUpsert: { ...baseModal },
ca_bc_eftTableConvert: { ...baseModal },
cardPayment: { ...baseModal }
cardPayment: { ...baseModal },
esignature: { ...baseModal }
};
const modalsReducer = (state = INITIAL_STATE, action) => {

View File

@@ -36,3 +36,4 @@ export const selectInventoryUpsert = createSelector([selectModals], (modals) =>
export const selectCaBcEtfTableConvert = createSelector([selectModals], (modals) => modals.ca_bc_eftTableConvert);
export const selectCardPayment = createSelector([selectModals], (modals) => modals.cardPayment);
export const selectEsignature = createSelector([selectModals], (modals) => modals.esignature);

View File

@@ -1,6 +1,6 @@
import { combineReducers } from "redux";
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
import storageModule from "redux-persist/lib/storage";
import { withReduxStateSync } from "redux-state-sync";
import applicationReducer from "./application/application.reducer";
import emailReducer from "./email/email.reducer";
@@ -11,6 +11,8 @@ import techReducer from "./tech/tech.reducer";
import userReducer from "./user/user.reducer";
import trelloReducer from "./trello/trello.reducer";
const storage = storageModule?.default ?? storageModule;
// const persistConfig = {
// key: "root",
// storage,

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "Error creating default view.",
"duplicate_insurance_company": "Duplicate insurance company name. Each insurance company name must be unique",
"loading": "Unable to load shop details. Please call technical support.",
"saving": "Error encountered while saving. {{message}}"
"saving": "Error encountered while saving. {{message}}",
"task_preset_allocation_exceeded": "{{laborType}} task preset total is {{total}}% and cannot exceed 100%."
},
"fields": {
"ReceivableCustomField": "QBO Receivable Custom Field {{number}}",
@@ -1175,12 +1176,26 @@
"new": "New Team",
"newmember": "New Team Member"
},
"errors": {
"allocation_total_exact": "Team allocation must total exactly 100%.",
"duplicate_member": "Each employee can only appear once per team.",
"minimum_one_member": "Add at least one team member."
},
"fields": {
"active": "Active",
"allocation_percentage": "Allocation %",
"employeeid": "Employee",
"max_load": "Max Load",
"name": "Team Name",
"payout_method": "Payout Method",
"percentage": "Percent"
},
"labels": {
"allocation_total": "Allocation Total: {{total}}%"
},
"options": {
"commission_percentage": "Commission %",
"hourly": "Hourly"
}
},
"employees": {
@@ -2188,6 +2203,7 @@
"duplicateconfirm": "Are you sure you want to duplicate this Job? Some elements of this Job will not be duplicated.",
"emailaudit": "Email Audit Trail",
"employeeassignments": "Employee Assignments",
"esignature": "E-Signature",
"estimatelines": "Estimate Lines",
"estimator": "Estimator",
"existing_jobs": "Existing Jobs",
@@ -3608,6 +3624,7 @@
"employee_team": "Employee Team",
"flat_rate": "Flat Rate?",
"memo": "Memo",
"pay": "Pay",
"productivehrs": "Productive Hours",
"ro_number": "Job to Post Against",
"task_name": "Task"

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "No se pueden cargar los detalles de la tienda. Por favor llame al soporte técnico.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,26 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -2188,6 +2203,7 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Empleos existentes",
@@ -3608,6 +3624,7 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"productivehrs": "",
"ro_number": "",
"task_name": ""

View File

@@ -305,7 +305,8 @@
"creatingdefaultview": "",
"duplicate_insurance_company": "",
"loading": "Impossible de charger les détails de la boutique. Veuillez appeler le support technique.",
"saving": ""
"saving": "",
"task_preset_allocation_exceeded": ""
},
"fields": {
"ReceivableCustomField": "",
@@ -1175,12 +1176,26 @@
"new": "",
"newmember": ""
},
"errors": {
"allocation_total_exact": "",
"duplicate_member": "",
"minimum_one_member": ""
},
"fields": {
"active": "",
"allocation_percentage": "",
"employeeid": "",
"max_load": "",
"name": "",
"payout_method": "",
"percentage": ""
},
"labels": {
"allocation_total": ""
},
"options": {
"commission_percentage": "",
"hourly": ""
}
},
"employees": {
@@ -2188,6 +2203,7 @@
"duplicateconfirm": "",
"emailaudit": "",
"employeeassignments": "",
"esignature": "",
"estimatelines": "",
"estimator": "",
"existing_jobs": "Emplois existants",
@@ -3608,6 +3624,7 @@
"employee_team": "",
"flat_rate": "",
"memo": "",
"pay": "",
"productivehrs": "",
"ro_number": "",
"task_name": ""

View File

@@ -5,13 +5,15 @@ import { RetryLink } from "@apollo/client/link/retry";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import apolloLogger from "apollo-link-logger";
import apolloLoggerModule from "apollo-link-logger";
import { createClient } from "graphql-ws";
import { map } from "rxjs/operators";
import { auth } from "../firebase/firebase.utils";
import errorLink from "../graphql/apollo-error-handling";
const apolloLogger = apolloLoggerModule?.default ?? apolloLoggerModule;
/**
* HTTP transport
*/

View File

@@ -47,6 +47,33 @@ const httpsCerts = {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pathSeparatorPattern = String.raw`[\\/]`;
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const packageChunkTest = (packageNames) => {
const names = Array.isArray(packageNames) ? packageNames : [packageNames];
return new RegExp(
`${pathSeparatorPattern}node_modules${pathSeparatorPattern}(?:${names
.map((name) => name.split("/").map(escapeRegex).join(pathSeparatorPattern))
.join("|")})(?:${pathSeparatorPattern}|$)`
);
};
const vendorCodeSplittingGroups = [
{ name: "antd", test: packageChunkTest("antd"), priority: 100 },
{ name: "react-redux", test: packageChunkTest("react-redux"), priority: 95 },
{ name: "redux", test: packageChunkTest("redux"), priority: 90 },
{ name: "lodash", test: packageChunkTest("lodash"), priority: 85 },
{ name: "@sentry/react", test: packageChunkTest("@sentry/react"), priority: 80 },
{ name: "@splitsoftware/splitio-react", test: packageChunkTest("@splitsoftware/splitio-react"), priority: 75 },
{ name: "logrocket", test: packageChunkTest("logrocket"), priority: 70 },
{ name: "firebase", test: packageChunkTest("@firebase"), priority: 65 },
{ name: "markerjs2", test: packageChunkTest("markerjs2"), priority: 60 },
{ name: "@apollo/client", test: packageChunkTest("@apollo/client"), priority: 55 },
{ name: "libphonenumber-js", test: packageChunkTest("libphonenumber-js"), priority: 50 },
{ name: "recharts", test: packageChunkTest("recharts"), priority: 45 }
];
export default defineConfig(({ command, mode }) => {
// React Compiler is always enabled for production/test builds
@@ -228,27 +255,13 @@ export default defineConfig(({ command, mode }) => {
build: {
sourcemap: true,
rollupOptions: {
rolldownOptions: {
output: {
manualChunks: {
antd: ["antd"],
"react-redux": ["react-redux"],
redux: ["redux"],
lodash: ["lodash"],
"@sentry/react": ["@sentry/react"],
"@splitsoftware/splitio-react": ["@splitsoftware/splitio-react"],
logrocket: ["logrocket"],
firebase: [
"@firebase/analytics",
"@firebase/app",
"@firebase/firestore",
"@firebase/auth",
"@firebase/messaging"
],
markerjs2: ["markerjs2"],
"@apollo/client": ["@apollo/client"],
"libphonenumber-js": ["libphonenumber-js"],
recharts: ["recharts"]
codeSplitting: {
groups: vendorCodeSplittingGroups
},
comments: {
legal: false
}
}
},
@@ -256,12 +269,6 @@ export default defineConfig(({ command, mode }) => {
cssMinify: "lightningcss"
},
// Strip console/debugger in prod to shrink bundles
esbuild: {
// drop: mode === "production" ? ["console", "debugger"] : [],
legalComments: "none" // Remove license comments in production
},
optimizeDeps: {
include: [
"react",
@@ -284,8 +291,8 @@ export default defineConfig(({ command, mode }) => {
"@firebase/util",
"styled-components"
],
esbuildOptions: {
loader: { ".jsx": "jsx", ".tsx": "tsx" }
rolldownOptions: {
moduleTypes: { ".jsx": "jsx", ".tsx": "tsx" }
},
// Force styled-components to be pre-bundled and deduplicated
force: mode === "development"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

3110
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -130,6 +130,7 @@ const applyRoutes = ({ app }) => {
app.use("/ai", require("./server/routes/aiRoutes"));
app.use("/chatter", require("./server/routes/chatterRoutes"));
app.use("/esign", require("./server/routes/esignRoutes"));
// Default route for forbidden access
app.get("/", (req, res) => {

304
server/esign/esign-new.js Normal file
View File

@@ -0,0 +1,304 @@
const { Documenso } = require("@documenso/sdk-typescript");
const axios = require("axios");
const { jsrAuthString } = require("../utils/utils");
const logger = require("../utils/logger");
const DOCUMENSO_API_KEY = "api_asojim0czruv13ud";//Done on a by team basis,
const documenso = new Documenso({
apiKey: DOCUMENSO_API_KEY,//Done on a by team basis,
serverURL: "https://stg-app.documenso.com/api/v2",
});
const JSR_SERVER = "https://reports.test.imex.online";
const jsreport = require("@jsreport/nodejs-client");
const { QUERY_JOB_FOR_SIGNATURE, INSERT_ESIG_AUDIT_TRAIL } = require("../graphql-client/queries");
async function distributeDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { documentId } = req.body;
const distributeResult = await documenso.documents.distribute({
documentId,
});
const auditEntry = await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: req.body.jobid,
bodyshopid: req.body.bodyshopid,
operation: `Esignature document with title ${distributeResult.title} (ID: ${documentId}) distributed to recipients.`,
useremail: req.user?.email,
type: 'esig-distribute'
}
})
res.json({ success: true, distributeResult });
} catch (error) {
console.error("Error distributing document:", error?.data);
logger.log(`esig-distribute-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while distributing the document." });
}
}
async function deleteDocument(req, res) {
try {
const { documentId } = req.body;
//TODO: This needs to be hardened to prevent deleting other people's documents, completed ones, etc.
const deleteResult = await documenso.documents.delete({
documentId
});
res.json({ success: true, deleteResult });
} catch (error) {
console.error("Error deleting document:", error?.data);
logger.log(`esig-delete-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while deleting the document." });
}
}
async function newEsignDocument(req, res) {
try {
const client = req.userGraphQLClient;
const { bodyshop } = req.body
const { pdf: fileBuffer, esigData } = await RenderTemplate({ client, req })
const fileBlob = new Blob([fileBuffer], { type: "application/pdf" });
//Get the Job data.
const { jobs_by_pk: jobData } = await client.request(QUERY_JOB_FOR_SIGNATURE, { jobid: req.body.jobid });
const createDocumentResponse = await documenso.documents.create({
payload: {
title: esigData?.title,
externalId: `${req.body.jobid}|${req.user?.email}`, //Have to pass the uploaded by later on. Limited to 255 chars.
recipients: [
{
email: "patrick@imexsystems.ca",//jobData.ownr_ea,
name: `${jobData.ownr_fn} ${jobData.ownr_ln}`,
role: "SIGNER",
}
],
meta: {
timezone: bodyshop.timezone,
dateFormat: "MM/dd/yyyy hh:mm a",
language: "en",
subject: esigData?.subject,
message: esigData?.message,
}
},
file: fileBlob
});
const documentResult = await documenso.documents.get({
documentId: createDocumentResponse.id,
});
if (esigData?.fields && esigData.fields.length > 0) {
try {
await documenso.envelopes.fields.createMany({
envelopeId: createDocumentResponse.envelopeId,
data: esigData.fields.map(sigField => ({ ...sigField, recipientId: documentResult.recipients[0].id, }))
});
} catch (error) {
logger.log(`esig-new-fields-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
}
}
const presignToken = await documenso.embedding.embeddingPresignCreateEmbeddingPresignToken({})
//add to job audit trail.
const auditEntry = await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: req.body.jobid,
bodyshopid: bodyshop.id,
operation: `Esignature document created. Subject: ${esigData?.subject || "No subject"}, Message: ${esigData?.message || "No message"}. Document ID: ${createDocumentResponse.id} Envlope ID: ${createDocumentResponse.envelopeId}`,
useremail: req.user?.email,
type: 'esig-create'
}
})
res.json({ token: presignToken.token, documentId: createDocumentResponse.id, envelopeId: createDocumentResponse.envelopeId });
}
catch (error) {
logger.log(`esig-new-error`, "ERROR", "esig", "api", {
message: error.message, stack: error.stack,
body: req.body
});
res.status(500).json({ error: "An error occurred while creating the e-sign document." });
}
}
async function RenderTemplate({ req }) {
//TODO Refactor to pull
const jsrAuth = jsrAuthString()
const jsreportClient = new jsreport("https://reports.test.imex.online", process.env.JSR_USER, process.env.JSR_PASSWORD);
const { templateObject, bodyshop } = req.body;
let { contextData, useShopSpecificTemplate, shopSpecificFolder, esigData } = await fetchContextData({ templateObject, jsrAuth, req });
const { ignoreCustomMargins } = { ignoreCustomMargins: false }// Templates[templateObject.name];
let reportRequest = {
template: {
name: useShopSpecificTemplate ? `/${bodyshop.imexshopid}/${templateObject.name}` : `/${templateObject.name}`,
recipe: "chrome-pdf",
...(!ignoreCustomMargins && {
chrome: {
marginTop:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.headerMargin &&
bodyshop.logo_img_path.headerMargin > 36
? bodyshop.logo_img_path.headerMargin
: "36px",
marginBottom:
bodyshop.logo_img_path &&
bodyshop.logo_img_path.footerMargin &&
bodyshop.logo_img_path.footerMargin > 50
? bodyshop.logo_img_path.footerMargin
: "50px"
}
}),
},
data: {
...contextData,
...templateObject.variables,
...templateObject.context,
headerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/header.html` : `/GENERIC/header.html`,
footerpath: shopSpecificFolder ? `/${bodyshop.imexshopid}/footer.html` : `/GENERIC/footer.html`,
bodyshop: bodyshop,
filters: templateObject?.filters,
sorters: templateObject?.sorters,
offset: bodyshop.timezone, //dayjs().utcOffset(),
defaultSorters: templateObject?.defaultSorters
}
};
const render = await jsreportClient.render(reportRequest);
//Check render object and download. It should be the PDF?
const pdfBuffer = await render.body()
return { pdf: pdfBuffer, esigData }
}
const fetchContextData = async ({ templateObject, jsrAuth, req, }) => {
const { bodyshop } = req.body
const folders = await axios.get(`${JSR_SERVER}/odata/folders`, {
headers: { Authorization: jsrAuth }
});
const shopSpecificFolder = folders.data.value.find((f) => f.name === bodyshop.imexshopid);
const jsReportQueries = await axios.get(
`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.query'`,
{ headers: { Authorization: jsrAuth } }
);
const jsReportEsig = await axios.get(
`${JSR_SERVER}/odata/assets?$filter=name eq '${templateObject.name}.esig'`,
{ headers: { Authorization: jsrAuth } }
);
let templateQueryToExecute;
let esigData;
let useShopSpecificTemplate = false;
// let shopSpecificTemplate;
if (shopSpecificFolder) {
let shopSpecificTemplate = jsReportQueries.data.value.find(
(f) => f?.folder?.shortid === shopSpecificFolder.shortid
);
if (shopSpecificTemplate) {
useShopSpecificTemplate = true;
templateQueryToExecute = atob(shopSpecificTemplate.content);
}
let shopSpecificEsig = jsReportEsig.data.value.find(
(f) => f?.folder?.shortid === shopSpecificFolder.shortid
);
if (shopSpecificEsig) {
esigData = (atob(shopSpecificEsig.content));
}
}
if (!templateQueryToExecute) {
const generalTemplate = jsReportQueries.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
templateQueryToExecute = atob(generalTemplate.content);
}
if (!esigData) {
const generalTemplate = jsReportEsig.data.value.find((f) => !f.folder);
useShopSpecificTemplate = false;
if (generalTemplate && generalTemplate.content) {
esigData = atob(generalTemplate?.content);
}
}
const client = req.userGraphQLClient;
// In the print center, we will never have sorters or filters.
// We have no template filters or sorters, so we can just execute the query and return the data
// if (!hasFilters && !hasSorters && !hasDefaultSorters) {
let contextData = {};
if (templateQueryToExecute) {
const data = await client.request(
templateQueryToExecute,
templateObject.variables,
);
contextData = data;
}
let parsedEsigData
try {
parsedEsigData = esigData ? JSON.parse(esigData) : null;
} catch (error) {
console.log("Error parsing esig data", error);
parsedEsigData = {}
}
return {
contextData,
useShopSpecificTemplate,
shopSpecificFolder,
esigData: parsedEsigData
};
// }
// return await generateTemplate(templateQueryToExecute, templateObject, useShopSpecificTemplate, shopSpecificFolder);
};
module.exports = {
newEsignDocument,
distributeDocument,
deleteDocument
}
// const sample_esig_for_jsr = {
// "fields": [
// {
// "placeholder": "[[signature]]",
// "type": "SIGNATURE"
// },
// {
// "placeholder": "[[date]]",
// "type": "DATE"
// }
// ],
// "subject": "CASL Auth Set in JSR",
// "message": "CASL Message set in JSR",
// "title": "CASL Title set in JSR"
// }

393
server/esign/webhook.js Normal file
View File

@@ -0,0 +1,393 @@
const { Documenso } = require("@documenso/sdk-typescript");
const fs = require("fs");
const path = require("path");
const logger = require("../utils/logger");
const { QUERY_META_FOR_ESIG_COMPLETION, INSERT_ESIGNATURE_DOCUMENT, INSERT_ESIG_AUDIT_TRAIL } = require("../graphql-client/queries");
const { uploadFileBuffer } = require("../media/imgproxy-media");
const client = require('../graphql-client/graphql-client').client;
const documenso = new Documenso({
apiKey: "api_asojim0czruv13ud",//Done on a by team basis,
serverURL: "https://stg-app.documenso.com/api/v2",
});
const webhookTypeEnums = {
DOCUMENT_CREATED: "DOCUMENT_CREATED",
DOCUMENT_SENT: "DOCUMENT_SENT",
DOCUMENT_COMPLETED: "DOCUMENT_COMPLETED",
DOCUMENT_REJECTED: "DOCUMENT_REJECTED",
DOCUMENT_CANCELLED: "DOCUMENT_CANCELLED",
DOCUMENT_OPENED: "DOCUMENT_OPENED",
DOCUMENT_SIGNED: "DOCUMENT_SIGNED",
}
async function esignWebhook(req, res) {
console.log("Esign Webhook Received:", req.body);
try {
const message = req.body
logger.log(`esig-webhook-received`, "DEBUG", "redis", "api", {
event: message.event,
body: message
});
switch (message.event) {
case webhookTypeEnums.DOCUMENT_CREATED:
//This is largely a throwaway event we know it was created.
console.log("Document created event received. Document ID:", message.payload.documentId);
// Here you can add any additional processing you want to do when a document is created
break;
case webhookTypeEnums.DOCUMENT_COMPLETED:
console.log("Document completed event received. Document ID:", message.payload.documentId);
await handleDocumentCompleted(message.payload);
// Here you can add any additional processing you want to do when a document is completed
break;
case webhookTypeEnums.DOCUMENT_SIGNED:
console.log("Document signed event received. Document ID:", message.payload.documentId);
// Here you can add any additional processing you want to do when a document is signed
break;
default:
console.log(`Unhandled event type: ${message.event}`);
}
// const result = await documenso.documents.download({
// documentId: req.body.payload.id,
// });
// result.resultingBuffer = Buffer.from(result.resultingArrayBuffer);
// // Save the document to a file for testing purposes
// const downloadsDir = path.join(__dirname, '../downloads');
// if (!fs.existsSync(downloadsDir)) {
// fs.mkdirSync(downloadsDir, { recursive: true });
// }
// const filePath = path.join(downloadsDir, `document_${req.body.payload.id}.pdf`);
// fs.writeFileSync(filePath, result.resultingBuffer);
// console.log(result)
res.sendStatus(200)
} catch (error) {
logger.log(`esig-webhook-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
body: req.body
});
// const downloadsDir = path.join(__dirname, '../downloads');
// if (!fs.existsSync(downloadsDir)) {
// fs.mkdirSync(downloadsDir, { recursive: true });
// }
// const filePath = path.join(downloadsDir, `document_${req.body.payload.id}.pdf`);
// fs.writeFileSync(filePath, Buffer.from(err.body));
// console.error("Error handling esign webhook:", err);
res.sendStatus(500)
}
}
async function handleDocumentCompleted(payload = sampleComplete) {
//Check if the bodyshop is on image proxy or not
try {
//Split the external id to get the uploaded user.
const [jobid, uploaded_by] = payload.externalId.split("|");
if (!jobid || !uploaded_by) {
throw new Error(`Invalid externalId format. Expected "jobid|uploaded_by", got "${payload.externalId}"`);
}
const { jobs_by_pk } = await client.request(QUERY_META_FOR_ESIG_COMPLETION, {
jobid
});
const document = await documenso.document.documentDownload({
documentId: payload.id,
});
const response = await fetch(document.downloadUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
let key = `${jobs_by_pk.bodyshop.id}/${jobs_by_pk.id}/${replaceAccents(document.filename).replace(/[^A-Z0-9]+/gi, "_")}-${new Date().getTime()}.pdf`;
if (jobs_by_pk?.bodyshop?.uselocalmediaserver) {
//LMS not yet implemented.
} else {
//S3 Upload
const uploadResult = await uploadFileBuffer({ key, buffer, contentType: "application/pdf" });
if (!uploadResult.success) {
logger.log(`esig-webhook-s3-upload-error`, "ERROR", "redis", "api", {
message: uploadResult.message,
stack: uploadResult.stack,
jobid: jobid,
documentId: payload.id
});
} else {
logger.log(`esig-webhook-s3-upload-success`, "INFO", "redis", "api", {
jobid: jobid,
documentId: payload.id,
s3Key: key,
bucket: uploadResult.bucket
});
const auditEntry = await client.request(INSERT_ESIG_AUDIT_TRAIL, {
obj: {
jobid: jobs_by_pk.id,
bodyshopid: jobs_by_pk.bodyshop.id,
operation: `Esignature document with title ${payload.title} (ID: ${payload.documentMeta.id}) has been completed.`,
useremail: uploaded_by,
type: 'esig-complete'
}
})
//insert the document record with the s3 key and bucket info.
await client.request(INSERT_ESIGNATURE_DOCUMENT, {
docInput: {
jobid: jobs_by_pk.id,
uploaded_by: uploaded_by,
key,
type: "application/pdf",
extension: "pdf",
bodyshopid: jobs_by_pk.bodyshop.id,
size: buffer.length,
takenat: new Date().toISOString(),
}
})
}
}
} catch (error) {
logger.log(`esig-webhook-event-completed-error`, "ERROR", "redis", "api", {
message: error.message, stack: error.stack,
payload
});
}
}
module.exports = {
esignWebhook
}
const sampleComplete = {
"id": 10929,
"title": "CASL Title set in JSR",
"source": "DOCUMENT",
"status": "COMPLETED",
"teamId": 742,
"userId": 654,
"Recipient": [
{
"id": 24997,
"name": "James Tschetter",
"role": "SIGNER",
"email": "patrick@imexsystems.ca",
"token": "uMom0GwL29NBqMfohGpUE",
"signedAt": "2026-02-27T22:11:52.835Z",
"expiresAt": "2026-05-28T22:10:48.991Z",
"documentId": 10929,
"readStatus": "OPENED",
"sendStatus": "SENT",
"templateId": null,
"authOptions": {
"accessAuth": [],
"actionAuth": []
},
"signingOrder": null,
"signingStatus": "SIGNED",
"rejectionReason": null,
"documentDeletedAt": null,
"expirationNotifiedAt": null
}
],
"createdAt": "2026-02-27T22:10:10.580Z",
"deletedAt": null,
"updatedAt": "2026-02-27T22:11:57.753Z",
"externalId": null,
"formValues": null,
"recipients": [
{
"id": 24997,
"name": "James Tschetter",
"role": "SIGNER",
"email": "patrick@imexsystems.ca",
"token": "uMom0GwL29NBqMfohGpUE",
"signedAt": "2026-02-27T22:11:52.835Z",
"expiresAt": "2026-05-28T22:10:48.991Z",
"documentId": 10929,
"readStatus": "OPENED",
"sendStatus": "SENT",
"templateId": null,
"authOptions": {
"accessAuth": [],
"actionAuth": []
},
"signingOrder": null,
"signingStatus": "SIGNED",
"rejectionReason": null,
"documentDeletedAt": null,
"expirationNotifiedAt": null
}
],
"templateId": null,
"visibility": "EVERYONE",
"authOptions": {
"globalAccessAuth": [],
"globalActionAuth": []
},
"completedAt": "2026-02-27T22:11:57.752Z",
"documentMeta": {
"id": "cmm5g3y7u00ecad1sv3ague1w",
"message": "CASL Message set in JSR",
"subject": "CASL Auth Set in JSR",
"language": "en",
"timezone": "Etc/UTC",
"dateFormat": "yyyy-MM-dd hh:mm a",
"redirectUrl": null,
"signingOrder": "PARALLEL",
"emailSettings": {
"documentDeleted": true,
"documentPending": true,
"recipientSigned": true,
"recipientRemoved": true,
"documentCompleted": true,
"ownerDocumentCompleted": true,
"recipientSigningRequest": true
},
"distributionMethod": "EMAIL",
"drawSignatureEnabled": true,
"typedSignatureEnabled": true,
"allowDictateNextSigner": false,
"uploadSignatureEnabled": true
}
}
// const sampleBody = {
// event: "DOCUMENT_COMPLETED",
// payload: {
// Recipient: [
// {
// authOptions: {
// accessAuth: [
// ],
// actionAuth: [
// ],
// },
// documentDeletedAt: null,
// documentId: 9827,
// email: "patrick@imexsystems.ca",
// expired: null,
// id: 13311,
// name: "Customer Fullname",
// readStatus: "OPENED",
// rejectionReason: null,
// role: "SIGNER",
// sendStatus: "SENT",
// signedAt: "2026-01-30T18:29:12.648Z",
// signingOrder: null,
// signingStatus: "SIGNED",
// templateId: null,
// token: "uiEWIsXUPTbWHd7QedVgt",
// },
// ],
// authOptions: {
// globalAccessAuth: [
// ],
// globalActionAuth: [
// ],
// },
// completedAt: "2026-01-30T18:29:16.279Z",
// createdAt: "2026-01-30T18:28:48.861Z",
// deletedAt: null,
// documentMeta: {
// allowDictateNextSigner: false,
// dateFormat: "yyyy-MM-dd hh:mm a",
// distributionMethod: "EMAIL",
// drawSignatureEnabled: true,
// emailSettings: {
// documentCompleted: true,
// documentDeleted: true,
// documentPending: true,
// ownerDocumentCompleted: true,
// recipientRemoved: false,
// recipientSigned: true,
// recipientSigningRequest: true,
// },
// id: "cml17vfb200qjad1t2spxnc1n",
// language: "en",
// message: "To perform repairs on your vehicle, we must receive digital authorization. Please review and sign the document to proceed with repairs. ",
// redirectUrl: null,
// signingOrder: "PARALLEL",
// subject: "Repair Authorization for ABC Collision",
// timezone: "Etc/UTC",
// typedSignatureEnabled: true,
// uploadSignatureEnabled: true,
// },
// externalId: null,
// formValues: null,
// id: 9827,
// recipients: [
// {
// authOptions: {
// accessAuth: [
// ],
// actionAuth: [
// ],
// },
// documentDeletedAt: null,
// documentId: 9827,
// email: "patrick@imexsystems.ca",
// expired: null,
// id: 13311,
// name: "Customer Fullname",
// readStatus: "OPENED",
// rejectionReason: null,
// role: "SIGNER",
// sendStatus: "SENT",
// signedAt: "2026-01-30T18:29:12.648Z",
// signingOrder: null,
// signingStatus: "SIGNED",
// templateId: null,
// token: "uiEWIsXUPTbWHd7QedVgt",
// },
// ],
// source: "DOCUMENT",
// status: "COMPLETED",
// teamId: 742,
// templateId: null,
// title: "Repair Authorization - 1/30/2026, 6:28:48 PM",
// updatedAt: "2026-01-30T18:29:16.280Z",
// userId: 654,
// visibility: "EVERYONE",
// },
// createdAt: "2026-01-30T18:29:18.504Z",
// webhookEndpoint: "https://dev.patrickfic.com/esign/webhook",
// }
function replaceAccents(str) {
// Verifies if the String has accents and replace them
if (str.search(/[\xC0-\xFF]/g) > -1) {
str = str
.replace(/[\xC0-\xC5]/g, "A")
.replace(/[\xC6]/g, "AE")
.replace(/[\xC7]/g, "C")
.replace(/[\xC8-\xCB]/g, "E")
.replace(/[\xCC-\xCF]/g, "I")
.replace(/[\xD0]/g, "D")
.replace(/[\xD1]/g, "N")
.replace(/[\xD2-\xD6\xD8]/g, "O")
.replace(/[\xD9-\xDC]/g, "U")
.replace(/[\xDD]/g, "Y")
.replace(/[\xDE]/g, "P")
.replace(/[\xE0-\xE5]/g, "a")
.replace(/[\xE6]/g, "ae")
.replace(/[\xE7]/g, "c")
.replace(/[\xE8-\xEB]/g, "e")
.replace(/[\xEC-\xEF]/g, "i")
.replace(/[\xF1]/g, "n")
.replace(/[\xF2-\xF6\xF8]/g, "o")
.replace(/[\xF9-\xFC]/g, "u")
.replace(/[\xFE]/g, "p")
.replace(/[\xFD\xFF]/g, "y");
}
return str;
}
`Unexpected Status or Content-Type: Status 200 Content-Type application/pdf\nBody: %PDF-1.7\n%<25><><EFBFBD><EFBFBD>\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n/Names 74 0 R\n/Dests 75 0 R\n/Info 77 0 R\n/Lang (en-US)\n/Version /1.7\n>>\nendobj\n77 0 obj\n<<\n/Type /Info\n/CreationDate (D:20260227230617Z00'00')\n/Producer <FEFF007000640066002D006C006900620020002800680074007400700073003A002F002F006700690074006800750062002E0063006F006D002F0048006F007000640069006E0067002F007000640066002D006C006900620029>\n/ModDate (D:20260227231057Z)…<>5[<5B>><3E>Wu7<><37>V<EFBFBD><56><EFBFBD><EFBFBD><EFBFBD>Pw<50>WX<57>ܮJ'6NWg<57>vYϳ<><CFB3><EFBFBD><EFBFBD><EFBFBD>Щr<D0A9>\n\t+<2B>1<EFBFBD><10>m{휑 <0C>hwb<><62><EFBFBD>8<EFBFBD><38>q y<>1e<31>)۱<>5m<35><6D><08><>MVM!<21>m<EFBFBD>[A<><41><10>{l<><6C>\t<EFBFBD>hia4<61><34>Tm<54><6D>8<><38>a<>e<EFBFBD>}<7D> ߫<><DFAB><15>]MVpяG<D18F><47>֏<EFBFBD>jJ<"<22>A<EFBFBD>mO*<2A>P<EFBFBD> <0B><><><7F><EFBFBD><EFBFBD>ѧЛ\nendstream\nendobj\n26 0 obj\n<<\n/Length 478/Filter /FlateDecode\n>>\nstream\nx<EFBFBD>MSK<EFBFBD>9<08><>)<29><>*<04>O<EFBFBD>i<EFBFBD><69>,<2C><>o <20><>kS%<25>$<EFBFBD><EFBFBD>hR\rS'<27>I<EFBFBD><49>~<7E><03><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>T[/<2F>{<05>k<EFBFBD>FC#<23><>֛<><D69B><EFBFBD>;Ӏ<>[<5B>⫀m<E2AB80>|Q<1F><>\x1b<EFBFBD><16>><3E> R<><52><EFBFBD><EFBFBD><EFBFBD>a<EFBFBD>E#<23>pI<70><49>._H<5F>ᆫt<E186AB>k<EFBFBD>D3p<33>I<EFBFBD><49><EFBFBD><EFBFBD><01>W2<57><32><EFBFBD>oJ0<4A>j<EFBFBD><6A><EFBFBD>j#<23><>!<21>$<EFBFBD><EFBFBD>-<2D><08><><EFBFBD><EFBFBD><EFBFBD><><CEAB><EFBFBD>TI|8D<38>H<1C><>Y<EFBFBD><59>x<EFBFBD><78><EFBFBD><EFBFBD>1<EFBFBD>73%<25>u<EFBFBD>T<EFBFBD><54>Ӑ.rcb<63>x<EFBFBD><78>Dd6=<3D><>Oڏ1 ^<5E>-<2D>...and 252354 more chars`

View File

@@ -0,0 +1,95 @@
export type WebhookEventType =
| "DOCUMENT_CREATED"
| "DOCUMENT_SENT"
| "DOCUMENT_COMPLETED"
| "DOCUMENT_REJECTED"
| "DOCUMENT_CANCELLED"
| "DOCUMENT_OPENED"
| "DOCUMENT_SIGNED";
export interface AuthOptions {
accessAuth: unknown[];
actionAuth: unknown[];
}
export interface Recipient {
id: number;
name: string;
role: string;
email: string;
token?: string | null;
signedAt?: string | null;
expiresAt?: string | null;
documentId?: number;
readStatus?: string | null;
sendStatus?: string | null;
templateId?: number | null;
authOptions?: AuthOptions;
signingOrder?: number | null;
signingStatus?: string | null;
rejectionReason?: string | null;
documentDeletedAt?: string | null;
expirationNotifiedAt?: string | null;
}
export interface EmailSettings {
documentDeleted: boolean;
documentPending: boolean;
recipientSigned: boolean;
recipientRemoved: boolean;
documentCompleted: boolean;
ownerDocumentCompleted: boolean;
recipientSigningRequest: boolean;
}
export interface DocumentMeta {
id: string;
message?: string | null;
subject?: string | null;
language?: string | null;
timezone?: string | null;
dateFormat?: string | null;
redirectUrl?: string | null;
signingOrder?: string | null;
emailSettings?: EmailSettings;
distributionMethod?: string | null;
drawSignatureEnabled?: boolean;
typedSignatureEnabled?: boolean;
allowDictateNextSigner?: boolean;
uploadSignatureEnabled?: boolean;
}
export interface DocumentAuthOptions {
globalAccessAuth: unknown[];
globalActionAuth: unknown[];
}
export interface DocumentPayload {
id: number;
title?: string | null;
source?: string | null;
status?: string | null;
teamId?: number | null;
userId?: number | null;
Recipient?: Recipient[];
recipients?: Recipient[];
createdAt?: string | null;
deletedAt?: string | null;
updatedAt?: string | null;
externalId?: string | null;
formValues?: unknown | null;
templateId?: number | null;
visibility?: string | null;
authOptions?: DocumentAuthOptions;
completedAt?: string | null;
documentMeta?: DocumentMeta | null;
}
export interface WebhookEventPayload {
event: WebhookEventType;
payload: DocumentPayload;
createdAt?: string | null;
webhookEndpoint?: string | null;
}
export default WebhookEventPayload;

View File

@@ -2253,18 +2253,16 @@ exports.UPDATE_OLD_TRANSITION = `mutation UPDATE_OLD_TRANSITION($jobid: uuid!, $
exports.INSERT_NEW_TRANSITION = (
includeOldTransition
) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${
includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : ""
) => `mutation INSERT_NEW_TRANSITION($newTransition: transitions_insert_input!, ${includeOldTransition ? `$oldTransitionId: uuid!, $duration: numeric` : ""
}) {
insert_transitions_one(object: $newTransition) {
id
}
${
includeOldTransition
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
${includeOldTransition
? `update_transitions(where: {id: {_eq: $oldTransitionId}}, _set: {duration: $duration}) {
affected_rows
}`
: ""
: ""
}
}`;
@@ -2463,6 +2461,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2473,6 +2473,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -2564,6 +2565,8 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
}
percentage
labor_rates
payout_method
commission_rates
}
}
}
@@ -2574,6 +2577,7 @@ exports.QUERY_JOB_PAYROLL_DATA = `query QUERY_JOB_PAYROLL_DATA($id: uuid!) {
productivehrs
actualhrs
ciecacode
payout_context
}
lbr_adjustments
ro_number
@@ -3250,3 +3254,46 @@ exports.SET_JOB_DMS_ID = `mutation SetJobDmsId($id: uuid!, $dms_id: String!, $dm
kmin
}
}`;
exports.QUERY_JOB_FOR_SIGNATURE = `query QUERY_JOB_FOR_SIGNATURE($jobid: uuid!) {
jobs_by_pk(id: $jobid) {
id
ownr_fn
ownr_ln
ownr_co_nm
ownr_ea
ownr_ph1
}
}
`
exports.INSERT_ESIG_AUDIT_TRAIL = `mutation INSERT_ESIG_AUDIT_TRAIL($obj: audit_trail_insert_input!) {
insert_audit_trail_one(object: $obj) {
id
}
}
`
exports.QUERY_META_FOR_ESIG_COMPLETION = `query QUERY_META_FOR_ESIG_COMPLETION($jobid: uuid!) {
jobs_by_pk(id: $jobid) {
id
ro_number
bodyshop {
id
uselocalmediaserver
localmediatoken
localmediaserverhttp
localmediaservernetwork
}
}
}`
exports.INSERT_ESIGNATURE_DOCUMENT = `mutation INSERT_ESIGNATURE_DOCUMENT($docInput: documents_insert_input!) {
insert_documents_one(object: $docInput) {
id
name
key
}
}
`

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,6 +94,47 @@ const generateSignedUploadUrls = async (req, res) => {
}
};
/**
* Upload a file buffer directly to S3.
* Accepts either `req.file.buffer` (e.g. from multer) or `req.body.buffer` (base64 string).
*/
const uploadFileBuffer = async ({ key, contentType, buffer }) => {
try {
if (!key) {
throw new Error("key is required");
}
if (!buffer) {
throw new Error("buffer is required");
}
const isPdf = key.toLowerCase().endsWith(".pdf");
const client = new S3Client({ region: InstanceRegion() });
const putParams = {
Bucket: imgproxyDestinationBucket,
Key: key,
Body: buffer,
StorageClass: "INTELLIGENT_TIERING"
};
if (contentType) {
putParams.ContentType = contentType;
} else if (isPdf) {
putParams.ContentType = "application/pdf";
}
await client.send(new PutObjectCommand(putParams));
return ({ success: true, key, bucket: imgproxyDestinationBucket });
} catch (error) {
return { success: false, message: error.message, stack: error.stack };
}
};
/**
* Get Thumbnail URLS
* @param req
@@ -500,6 +541,7 @@ const keyStandardize = (doc) => {
module.exports = {
generateSignedUploadUrls,
uploadFileBuffer,
getThumbnailUrls,
getOriginalImageByDocumentId,
downloadFiles,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,367 @@
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.");
});
});
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%."
});
});
});

View File

@@ -0,0 +1,17 @@
const express = require("express");
const router = express.Router();
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
const { newEsignDocument, distributeDocument, deleteDocument } = require("../esign/esign-new");
const { esignWebhook } = require("../esign/webhook");
//router.use(validateFirebaseIdTokenMiddleware);
router.post("/new", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, newEsignDocument);
router.post("/distribute", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, distributeDocument);
router.post("/delete", validateFirebaseIdTokenMiddleware, withUserGraphQLClientMiddleware, deleteDocument);
router.post("/webhook", esignWebhook);
module.exports = router;

View File

@@ -2,6 +2,9 @@ exports.servertime = (req, res) => {
res.status(200).send(new Date());
};
exports.jsrAuthString =() => {
return "Basic " + Buffer.from(`${process.env.JSR_USER}:${process.env.JSR_PASSWORD}`).toString("base64")
}
exports.jsrAuth = async (req, res) => {
res.send("Basic " + Buffer.from(`${process.env.JSR_USER}:${process.env.JSR_PASSWORD}`).toString("base64"));
res.send(exports.jsrAuthString());
};