diff --git a/Dockerfile b/Dockerfile
index b1d253808..16e9d2159 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,6 @@ RUN dnf install -y git \
&& dnf install -y nodejs \
&& dnf clean all
-
# Install dependencies required by node-canvas
RUN dnf install -y \
gcc \
@@ -19,9 +18,22 @@ RUN dnf install -y \
libpng-devel \
make \
python3 \
+ fontconfig \
+ freetype \
python3-pip \
+ wget \
+ unzip \
&& dnf clean all
+# Install Montserrat fonts
+RUN cd /tmp \
+ && wget https://images.imex.online/fonts/montserrat.zip -O montserrat.zip \
+ && unzip montserrat.zip -d montserrat \
+ && mv montserrat/montserrat/*.ttf /usr/share/fonts \
+ && fc-cache -fv \
+ && rm -rf /tmp/montserrat /tmp/montserrat.zip \
+ && echo "Montserrat fonts installed and cached successfully."
+
# Set the working directory
WORKDIR /app
diff --git a/client/src/components/owner-detail-form/owner-detail-form.component.jsx b/client/src/components/owner-detail-form/owner-detail-form.component.jsx
index 419de25fa..5de4231f7 100644
--- a/client/src/components/owner-detail-form/owner-detail-form.component.jsx
+++ b/client/src/components/owner-detail-form/owner-detail-form.component.jsx
@@ -25,6 +25,9 @@ export default function OwnerDetailFormComponent({ form, loading }) {
+
+
+
diff --git a/client/src/graphql/owners.queries.js b/client/src/graphql/owners.queries.js
index e28fc8aa2..d169263f4 100644
--- a/client/src/graphql/owners.queries.js
+++ b/client/src/graphql/owners.queries.js
@@ -48,6 +48,7 @@ export const QUERY_OWNER_BY_ID = gql`
query QUERY_OWNER_BY_ID($id: uuid!) {
owners_by_pk(id: $id) {
id
+ accountingid
allow_text_message
ownr_addr1
ownr_addr2
diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json
index e86e9936e..337047e1f 100644
--- a/client/src/translations/en_us/common.json
+++ b/client/src/translations/en_us/common.json
@@ -2394,6 +2394,7 @@
"selectexistingornew": "Select an existing owner record or create a new one. "
},
"fields": {
+ "accountingid": "Accounting ID",
"address": "Address",
"allow_text_message": "Permission to Text?",
"name": "Name",
@@ -3057,6 +3058,7 @@
"production_not_production_status": "Production not in Production Status",
"production_over_time": "Production Level over Time",
"psr_by_make": "Percent of Sales by Vehicle Make",
+ "purchase_return_ratio_excel": "Purchase & Return Ratio - Excel",
"purchase_return_ratio_grouped_by_vendor_detail": "Purchase & Return Ratio by Vendor (Detail)",
"purchase_return_ratio_grouped_by_vendor_summary": "Purchase & Return Ratio by Vendor (Summary)",
"purchases_by_cost_center_detail": "Purchases by Cost Center (Detail)",
@@ -3082,6 +3084,7 @@
"timetickets": "Time Tickets",
"timetickets_employee": "Employee Time Tickets",
"timetickets_summary": "Time Tickets Summary",
+ "total_loss_jobs": "Jobs Marked as Total Loss",
"unclaimed_hrs": "Unflagged Hours",
"void_ros": "Void ROs",
"work_in_progress_committed_labour": "Work in Progress - Committed Labor",
diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json
index 8b9026489..02c3dd2dd 100644
--- a/client/src/translations/es/common.json
+++ b/client/src/translations/es/common.json
@@ -2394,6 +2394,7 @@
"selectexistingornew": ""
},
"fields": {
+ "accountingid": "",
"address": "Dirección",
"allow_text_message": "Permiso de texto?",
"name": "Nombre",
@@ -3057,6 +3058,7 @@
"production_not_production_status": "",
"production_over_time": "",
"psr_by_make": "",
+ "purchase_return_ratio_excel": "",
"purchase_return_ratio_grouped_by_vendor_detail": "",
"purchase_return_ratio_grouped_by_vendor_summary": "",
"purchases_by_cost_center_detail": "",
@@ -3082,6 +3084,7 @@
"timetickets": "",
"timetickets_employee": "",
"timetickets_summary": "",
+ "total_loss_jobs": "",
"unclaimed_hrs": "",
"void_ros": "",
"work_in_progress_committed_labour": "",
diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json
index 8f9330ea0..7ea7ef02c 100644
--- a/client/src/translations/fr/common.json
+++ b/client/src/translations/fr/common.json
@@ -2394,6 +2394,7 @@
"selectexistingornew": ""
},
"fields": {
+ "accountingid": "",
"address": "Adresse",
"allow_text_message": "Autorisation de texte?",
"name": "Prénom",
@@ -3057,6 +3058,7 @@
"production_not_production_status": "",
"production_over_time": "",
"psr_by_make": "",
+ "purchase_return_ratio_excel": "",
"purchase_return_ratio_grouped_by_vendor_detail": "",
"purchase_return_ratio_grouped_by_vendor_summary": "",
"purchases_by_cost_center_detail": "",
@@ -3082,6 +3084,7 @@
"timetickets": "",
"timetickets_employee": "",
"timetickets_summary": "",
+ "total_loss_jobs": "",
"unclaimed_hrs": "",
"void_ros": "",
"work_in_progress_committed_labour": "",
diff --git a/client/src/utils/TemplateConstants.js b/client/src/utils/TemplateConstants.js
index 9f80986d2..9e40624c0 100644
--- a/client/src/utils/TemplateConstants.js
+++ b/client/src/utils/TemplateConstants.js
@@ -2184,6 +2184,30 @@ export const TemplateList = (type, context) => {
},
group: "payroll",
adp_payroll: true
+ },
+ purchase_return_ratio_excel: {
+ title: i18n.t("reportcenter.templates.purchase_return_ratio_excel"),
+ subject: i18n.t("reportcenter.templates.purchase_return_ratio_excel"),
+ key: "purchase_return_ratio_excel",
+ //idtype: "vendor",
+ reporttype: "excel",
+ disabled: false,
+ rangeFilter: {
+ object: i18n.t("reportcenter.labels.objects.bills"),
+ field: i18n.t("bills.fields.date")
+ },
+ group: "purchases"
+ },
+ total_loss_jobs: {
+ title: i18n.t("reportcenter.templates.total_loss_jobs"),
+ subject: i18n.t("reportcenter.templates.total_loss_jobs"),
+ key: "total_loss_jobs",
+ disabled: false,
+ rangeFilter: {
+ object: i18n.t("reportcenter.labels.objects.jobs"),
+ field: i18n.t("jobs.fields.date_open")
+ },
+ group: "jobs"
}
}
: {}),
diff --git a/package-lock.json b/package-lock.json
index dc75214ae..43476030d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,6 +42,7 @@
"intuit-oauth": "^4.1.3",
"ioredis": "^5.4.1",
"json-2-csv": "^5.5.6",
+ "juice": "^11.0.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
@@ -53,6 +54,7 @@
"recursive-diff": "^1.0.9",
"redis": "^4.7.0",
"rimraf": "^6.0.1",
+ "skia-canvas": "^2.0.0",
"soap": "^1.1.6",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
@@ -3500,6 +3502,15 @@
"node": ">= 6.0.0"
}
},
+ "node_modules/ansi-colors": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
+ "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -3875,6 +3886,15 @@
"node": ">=6"
}
},
+ "node_modules/cargo-cp-artifact": {
+ "version": "0.1.9",
+ "resolved": "https://registry.npmjs.org/cargo-cp-artifact/-/cargo-cp-artifact-0.1.9.tgz",
+ "integrity": "sha512-6F+UYzTaGB+awsTXg0uSJA1/b/B3DDJzpKVRu0UmyI7DmNeaAl2RFHuTGIN6fEgpadRxoXGb7gbC1xo4C3IdyA==",
+ "license": "MIT",
+ "bin": {
+ "cargo-cp-artifact": "bin/cargo-cp-artifact.js"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4074,6 +4094,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
@@ -4849,6 +4878,31 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz",
+ "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/encoding-sniffer/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -4935,6 +4989,18 @@
"node": ">=6"
}
},
+ "node_modules/escape-goat": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz",
+ "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -6365,6 +6431,69 @@
"safe-buffer": "^5.0.1"
}
},
+ "node_modules/juice": {
+ "version": "11.0.0",
+ "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz",
+ "integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio": "^1.0.0",
+ "commander": "^12.1.0",
+ "mensch": "^0.3.4",
+ "slick": "^1.12.2",
+ "web-resource-inliner": "^7.0.0"
+ },
+ "bin": {
+ "juice": "bin/juice"
+ },
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
+ "node_modules/juice/node_modules/cheerio": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz",
+ "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.1.0",
+ "encoding-sniffer": "^0.2.0",
+ "htmlparser2": "^9.1.0",
+ "parse5": "^7.1.2",
+ "parse5-htmlparser2-tree-adapter": "^7.0.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^6.19.5",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18.17"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/juice/node_modules/htmlparser2": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz",
+ "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.1.0",
+ "entities": "^4.5.0"
+ }
+ },
"node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
@@ -6631,6 +6760,12 @@
"cssom": "^0.5.0"
}
},
+ "node_modules/mensch": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz",
+ "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==",
+ "license": "MIT"
+ },
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -7136,6 +7271,12 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw=="
},
+ "node_modules/parenthesis": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz",
+ "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==",
+ "license": "MIT"
+ },
"node_modules/parse5": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
@@ -7159,6 +7300,18 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -7168,6 +7321,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "license": "MIT"
+ },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -7847,6 +8006,73 @@
"is-arrayish": "^0.3.1"
}
},
+ "node_modules/skia-canvas": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/skia-canvas/-/skia-canvas-2.0.0.tgz",
+ "integrity": "sha512-wpYkmr9mCxBme5HAnlm6YOEiuaN9tIm9CL+HN8e5AFD4K2FAJXCcWiWvc9+LM8jUXt+AyYXgiwUTBxdQ6P+PEg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@mapbox/node-pre-gyp": "^1.0.11",
+ "cargo-cp-artifact": "^0.1",
+ "glob": "^11.0.0",
+ "path-browserify": "^1.0.1",
+ "simple-get": "^4.0.1",
+ "string-split-by": "^1.0.0"
+ }
+ },
+ "node_modules/skia-canvas/node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/skia-canvas/node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/skia-canvas/node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
"node_modules/slick": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz",
@@ -8239,6 +8465,15 @@
}
]
},
+ "node_modules/string-split-by": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz",
+ "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==",
+ "license": "MIT",
+ "dependencies": {
+ "parenthesis": "^3.1.5"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8688,6 +8923,15 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/undici": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz",
+ "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.17"
+ }
+ },
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@@ -8749,6 +8993,15 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/valid-data-url": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz",
+ "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -8773,6 +9026,131 @@
"node": ">=6.0"
}
},
+ "node_modules/web-resource-inliner": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-7.0.0.tgz",
+ "integrity": "sha512-NlfnGF8MY9ZUwFjyq3vOUBx7KwF8bmE+ywR781SB0nWB6MoMxN4BA8gtgP1KGTZo/O/AyWJz7HZpR704eaj4mg==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-colors": "^4.1.1",
+ "escape-goat": "^3.0.0",
+ "htmlparser2": "^5.0.0",
+ "mime": "^2.4.6",
+ "valid-data-url": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/dom-serializer": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
+ "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^4.2.0",
+ "entities": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/domhandler": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz",
+ "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/domutils": {
+ "version": "2.8.0",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
+ "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^1.0.1",
+ "domelementtype": "^2.2.0",
+ "domhandler": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
+ "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.2.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/entities": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
+ "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
+ "license": "BSD-2-Clause",
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/htmlparser2": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz",
+ "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.0.1",
+ "domhandler": "^3.3.0",
+ "domutils": "^2.4.2",
+ "entities": "^2.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/htmlparser2?sponsor=1"
+ }
+ },
+ "node_modules/web-resource-inliner/node_modules/mime": {
+ "version": "2.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
+ "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -8801,6 +9179,30 @@
"node": ">=0.8.0"
}
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-encoding/node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
diff --git a/package.json b/package.json
index 288eb99c8..a54cf08a1 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"intuit-oauth": "^4.1.3",
"ioredis": "^5.4.1",
"json-2-csv": "^5.5.6",
+ "juice": "^11.0.0",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"moment-timezone": "^0.5.46",
@@ -63,6 +64,7 @@
"recursive-diff": "^1.0.9",
"redis": "^4.7.0",
"rimraf": "^6.0.1",
+ "skia-canvas": "^2.0.0",
"soap": "^1.1.6",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
diff --git a/server/accounting/qbo/qbo-receivables.js b/server/accounting/qbo/qbo-receivables.js
index f57c6711a..c7c74928b 100644
--- a/server/accounting/qbo/qbo-receivables.js
+++ b/server/accounting/qbo/qbo-receivables.js
@@ -328,6 +328,7 @@ async function InsertOwner(oauthClient, qbo_realmId, req, job, isThreeTier, pare
PostalCode: job.ownr_zip,
CountrySubDivisionCode: job.ownr_st
},
+ ...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {}),
...(isThreeTier
? {
Job: true,
@@ -395,7 +396,7 @@ async function InsertJob(oauthClient, qbo_realmId, req, job, parentTierRef) {
PostalCode: job.ownr_zip,
CountrySubDivisionCode: job.ownr_st
},
-
+ ...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {}),
Job: true,
ParentRef: {
value: parentTierRef.Id
@@ -556,7 +557,8 @@ async function InsertInvoice(oauthClient, qbo_realmId, req, job, bodyshop, paren
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
Line2: job.ownr_addr1 || "",
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
- }
+ },
+ ...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
};
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
@@ -673,7 +675,8 @@ async function InsertInvoiceMultiPayerInvoice(
Line3: `${job.ownr_city || ""}, ${job.ownr_st || ""} ${job.ownr_zip || ""}`.trim(),
Line2: job.ownr_addr1 || "",
Line1: `${job.ownr_fn || ""} ${job.ownr_ln || ""} ${job.ownr_co_nm || ""}`
- }
+ },
+ ...(job.ownr_ea ? { BillEmail: { Address: job.ownr_ea.trim() } } : {})
};
logger.log("qbo-receivable-objectlog", "DEBUG", req.user.email, job.id, {
diff --git a/server/middleware/validateCanvasInputMiddleware.js b/server/middleware/validateCanvasInputMiddleware.js
new file mode 100644
index 000000000..a03bb12ec
--- /dev/null
+++ b/server/middleware/validateCanvasInputMiddleware.js
@@ -0,0 +1,32 @@
+const { isObject } = require("lodash");
+
+const validateCanvasInputMiddleware = (req, res, next) => {
+ const { values, keys, override, w, h } = req.body;
+
+ if (!Array.isArray(values) || !Array.isArray(keys)) {
+ return res.status(400).send("Invalid input: 'values' and 'keys' must be arrays.");
+ }
+
+ if (values.some((value) => typeof value !== "number")) {
+ return res.status(400).send("Invalid input: 'values' must be an array of numbers.");
+ }
+
+ if (keys.some((key) => typeof key !== "string")) {
+ return res.status(400).send("Invalid input: 'keys' must be an array of strings.");
+ }
+
+ if (override && !isObject(override)) {
+ return res.status(400).send("Override must be an object");
+ }
+
+ if (w && (!Number.isFinite(w) || w <= 0)) {
+ return res.status(400).send("Width must be a positive number");
+ }
+ if (h && (!Number.isFinite(h) || h <= 0)) {
+ return res.status(400).send("Height must be a positive number");
+ }
+
+ next(); // Proceed to the next middleware or route handler
+};
+
+module.exports = validateCanvasInputMiddleware;
diff --git a/server/render/canvas-handler.js b/server/render/canvas-handler.js
index 0af2a7f53..5b810dd73 100644
--- a/server/render/canvas-handler.js
+++ b/server/render/canvas-handler.js
@@ -1,43 +1,31 @@
const { createCanvas } = require("canvas");
+const { Canvas, FontLibrary } = require("skia-canvas");
const Chart = require("chart.js/auto");
-const logger = require("../utils/logger");
const { backgroundColors, borderColors } = require("./canvas-colors");
-const { isObject, defaultsDeep, isNumber } = require("lodash");
+const { defaultsDeep, isNumber } = require("lodash");
-exports.canvastest = function (req, res) {
- //console.log("Incoming test request.", req);
- res.status(200).send("OK");
-};
+const CANVAS_QUEUE_LIMIT = 100;
-exports.canvas = function (req, res) {
- const { w, h, values, keys, override } = req.body;
- //console.log("Incoming Canvas Request:", w, h, values, keys, override);
- logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
- // Gate required values
- if (!values || !keys) {
- res.status(400).send("Missing required data");
- return;
- }
+let isProcessing = false;
+const requestQueue = [];
- // Override must be an object if it exists
- if (override && !isObject(override)) {
- res.status(400).send("Override must be an object");
- return;
- }
+try {
+ FontLibrary.use("Montserrat", [
+ "/usr/share/fonts/Montserrat-Regular.ttf",
+ "/usr/share/fonts/Montserrat-Bold.ttf",
+ "/usr/share/fonts/Montserrat-Italic.ttf"
+ ]);
+} catch (error) {
+ console.error(
+ "Error loading fonts Skia Canvas Fonts, please be sure to install Montserrat font package",
+ error.message
+ );
+}
- // Set the default Width and Height
- let [width, height] = [500, 275];
-
- // Allow for custom width and height
- if (isNumber(w)) {
- width = w;
- }
- if (isNumber(h)) {
- height = h;
- }
-
- const configuration = {
+// Utility to create a chart configuration
+const getChartConfiguration = (keys, values, override) => {
+ const defaultConfiguration = {
type: "doughnut",
data: {
labels: keys,
@@ -53,6 +41,7 @@ exports.canvas = function (req, res) {
options: {
devicePixelRatio: 4,
responsive: false,
+ animation: false,
maintainAspectRatio: true,
circumference: 180,
rotation: -90,
@@ -73,21 +62,88 @@ exports.canvas = function (req, res) {
}
};
- // If we have a valid override object, merge it with the default configuration object.
- // This allows for you to override the default configuration with a custom one.
- const defaults = () => {
- if (!override || !isObject(override)) {
- return configuration;
- }
- return defaultsDeep(override, configuration);
- };
-
- res.status(200).send(
- (() => {
- const canvas = createCanvas(width, height);
- const ctx = canvas.getContext("2d");
- new Chart(ctx, defaults());
- return canvas.toDataURL();
- })()
- );
+ return defaultsDeep(override || {}, defaultConfiguration);
+};
+
+const processCanvasRequest = async (req, res, isSkia = false) => {
+ const { logger } = req;
+ const { w, h, values, keys, override } = req.body;
+
+ logger.log("inbound-canvas-creation", "debug", "jsr", null, { w, h, values, keys, override });
+
+ // Default width and height
+ const width = isNumber(w) && w > 0 ? w : 500;
+ const height = isNumber(h) && h > 0 ? h : 275;
+
+ const configuration = getChartConfiguration(keys, values, override);
+
+ // Placeholders to allow fine control over GAC
+ let canvas = null;
+ let ctx = null;
+ let chart = null;
+ let chartImage = null;
+
+ try {
+ // Create the canvas
+ canvas = isSkia ? new Canvas(width, height) : createCanvas(width, height);
+ ctx = canvas.getContext("2d");
+
+ // Render the chart
+ chart = new Chart(ctx, configuration);
+
+ // Generate and send the image
+ chartImage = isSkia ? (await canvas.toBuffer("image/png")).toString("base64") : canvas.toDataURL();
+
+ res.status(200).send(isSkia ? `data:image/png;base64,${chartImage}` : chartImage);
+ } catch (error) {
+ // Log the error and send the response
+ logger.log("canvas-error", "error", "jsr", null, { error: error.message });
+ res.status(500).send("Failed to generate canvas.");
+ } finally {
+ // Cleanup resources
+ if (chart) {
+ chart.destroy();
+ }
+ ctx = null; // Explicitly nullify for garbage collection
+ canvas = null; // Explicitly nullify for garbage collection
+ chartImage = null;
+ }
+};
+
+const enqueueRequest = (req, res, isSkia) => {
+ if (requestQueue.length >= CANVAS_QUEUE_LIMIT) {
+ res.status(503).send("Server is busy. Please try again later.");
+ return false;
+ }
+ requestQueue.push({ req, res, isSkia });
+ req.logger.log("inbound-canvas-creation-queue", "debug", "jsr", null, { queue: requestQueue.length });
+ return true;
+};
+
+const processNextInQueue = async () => {
+ while (requestQueue.length > 0) {
+ const { req, res, isSkia } = requestQueue.shift();
+ try {
+ await processCanvasRequest(req, res, isSkia);
+ } catch (err) {
+ console.error("canvas-queue-error", "error", "jsr", null, { error: err.message });
+ }
+ }
+ isProcessing = false;
+};
+
+exports.canvastest = function (req, res) {
+ res.status(200).send("OK");
+};
+
+exports.canvas = async (req, res) => {
+ if (isProcessing || !enqueueRequest(req, res, false)) return;
+ isProcessing = true;
+ processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
+};
+
+exports.canvasSkia = async (req, res) => {
+ if (isProcessing || !enqueueRequest(req, res, true)) return;
+ isProcessing = true;
+ processNextInQueue().catch((err) => console.error("canvas-processing-error", { error: err.message }));
};
diff --git a/server/render/inlinecss.js b/server/render/inlinecss.js
index 07fb68052..74700b210 100644
--- a/server/render/inlinecss.js
+++ b/server/render/inlinecss.js
@@ -3,24 +3,36 @@ require("dotenv").config({
path: path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || "development"}`)
});
const logger = require("../utils/logger");
-const inlineCssTool = require("inline-css");
+//const inlineCssTool = require("inline-css");
+const juice = require("juice");
-exports.inlinecss = (req, res) => {
+exports.inlinecss = async (req, res) => {
//Perform request validation
-
logger.log("email-inline-css", "DEBUG", req.user.email, null, null);
const { html, url } = req.body;
-
- inlineCssTool(html, { url: url })
- .then((inlinedHtml) => {
- res.send(inlinedHtml);
- })
- .catch((error) => {
- logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
- error
- });
-
- res.send(error);
+ try {
+ const inlinedHtml = juice(html, {
+ applyAttributesTableElements: false,
+ preserveMediaQueries: false,
+ applyWidthAttributes: false
});
+ res.send(inlinedHtml);
+ } catch (error) {
+ logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
+ error
+ });
+ res.send(error.message);
+ }
+
+ // inlineCssTool(html, { url: url })
+ // .then((inlinedHtml) => {
+ // res.send(inlinedHtml);
+ // })
+ // .catch((error) => {
+ // logger.log("email-inline-css-error", "ERROR", req.user.email, null, {
+ // error
+ // });
+
+ // });
};
diff --git a/server/routes/renderRoutes.js b/server/routes/renderRoutes.js
index 13288d989..c1423f698 100644
--- a/server/routes/renderRoutes.js
+++ b/server/routes/renderRoutes.js
@@ -2,10 +2,12 @@ const express = require("express");
const router = express.Router();
const { inlinecss } = require("../render/inlinecss");
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
-const { canvas } = require("../render/canvas-handler");
+const { canvas, canvasSkia } = require("../render/canvas-handler");
+const validateCanvasInputMiddleware = require("../middleware/validateCanvasInputMiddleware");
// Define the route for inline CSS rendering
router.post("/inlinecss", validateFirebaseIdTokenMiddleware, inlinecss);
-router.post("/canvas", validateFirebaseIdTokenMiddleware, canvas);
+router.post("/canvas", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvas);
+router.post("/canvas-skia", [validateFirebaseIdTokenMiddleware, validateCanvasInputMiddleware], canvasSkia);
module.exports = router;