Compare commits

..

33 Commits

Author SHA1 Message Date
Dave Richer
28abd9707e feature/IO-3303-logging - Logging 2025-07-14 19:31:31 -04:00
Dave Richer
5f621e1ae0 feature/IO-3303-logging - Logging 2025-07-14 19:28:29 -04:00
Patrick Fic
fbd6766dcd Merged in feature/IO-3294-imgproxy-bill-upload-hotfix (pull request #2401)
IO-3294 fix upload to image proxy for bills.
2025-07-08 21:46:35 +00:00
Patrick Fic
9ace531edb IO-3294 fix upload to image proxy for bills. 2025-07-08 14:31:32 -07:00
Dave Richer
9c59fd4c00 Merged in release/2025-07-04 (pull request #2393)
DO NOT MERGE - PENDING Release/2025 07 04  into master-AIO - IO-3284 IO-3285 IO-3286 IO-3288
2025-07-05 01:21:35 +00:00
Allan Carr
a9f959cced Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2394)
IO-3286 Additional Product Fruit IDs

Approved-by: Dave Richer
2025-07-04 21:03:37 +00:00
Allan Carr
414897bba0 IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-07-04 13:35:32 -07:00
Allan Carr
7467a31d76 Merged in feature/IO-3284-JobLine-Price-Lbr-Hrs-Totals (pull request #2387)
IO-3284 JobLine Price Lbr Hrs Total

Approved-by: Dave Richer
2025-06-27 18:24:16 +00:00
Allan Carr
894f6bf6d2 Merged in feature/IO-3285-Shop-Config-Lite-Basic (pull request #2388)
IO-3285 Shop Config Lite Basic

Approved-by: Dave Richer
2025-06-27 18:23:41 +00:00
Allan Carr
744dfa8163 Merged in feature/IO-3286-Additional-Product-Fruit-IDs (pull request #2389)
IO-3286 Additional Product Fruit IDs

Approved-by: Dave Richer
2025-06-27 18:22:56 +00:00
Dave Richer
2293119518 Merged in feature/IO-3288-Score-Board-Blocked-Days (pull request #2390)
feature/IO-3288-Score-Board-Blocked-Days - Fix
2025-06-27 17:57:25 +00:00
Dave Richer
bd529a0dfa feature/IO-3288-Score-Board-Blocked-Days - Fix 2025-06-27 13:56:18 -04:00
Allan Carr
57ad89747f IO-3286 Additional Product Fruit IDs
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:48:09 -07:00
Allan Carr
3ae8f38adb IO-3285 Shop Config Lite Basic
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:45:48 -07:00
Allan Carr
dc5ed1a39c IO-3284 JobLine Price Lbr Hrs Total
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-06-26 18:40:38 -07:00
Patrick Fic
aa6e6b8980 Merged in hotfix/2025-06-25 (pull request #2386)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:37 +00:00
Patrick Fic
1dc80c068b Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2385)
IO-3281 Resolve key issue for downloads.
2025-06-25 23:33:14 +00:00
Patrick Fic
bd0c4ceae2 IO-3281 Resolve key issue for downloads. 2025-06-25 16:32:47 -07:00
Patrick Fic
30b58c6ea5 Merged in hotfix/2025-06-25 (pull request #2384)
IO-3281 missed file in previous commit.
2025-06-25 22:48:53 +00:00
Patrick Fic
a55e9224f8 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2383)
IO-3281 missed file in previous commit.
2025-06-25 22:48:41 +00:00
Patrick Fic
0c80abb3ca IO-3281 missed file in previous commit. 2025-06-25 15:48:06 -07:00
Patrick Fic
7137e611cd Merged in hotfix/2025-06-25 (pull request #2382)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:37:01 +00:00
Patrick Fic
6f9d291d36 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2381)
IO-3281 Prevent broken stream reseting HTTP headers.
2025-06-25 22:36:47 +00:00
Patrick Fic
f2a2653eae IO-3281 Prevent broken stream reseting HTTP headers. 2025-06-25 15:36:03 -07:00
Patrick Fic
73c25ab91f Merged in hotfix/2025-06-25 (pull request #2380)
Hotfix/2025 06 25 - IO-3279 IO-3281
2025-06-25 17:08:57 +00:00
Patrick Fic
780449bac6 Merge branch 'hotfix/2025-06-25' of bitbucket.org:snaptsoft/bodyshop into hotfix/2025-06-25 2025-06-25 10:08:09 -07:00
Patrick Fic
2509a1ecf3 Merge branch 'feature/IO-3279-usage-report' into hotfix/2025-06-25 2025-06-25 10:08:02 -07:00
Patrick Fic
16075f7ddd Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2378)
IO-3281 Adjust zip to stream.
2025-06-25 16:43:29 +00:00
Patrick Fic
27d28e7ffc IO-3281 Adjust zip to stream. 2025-06-25 09:42:45 -07:00
Patrick Fic
66b87e5c45 Merged in feature/IO-3281-imgproxy-download-hotfix (pull request #2376)
IO-3281 resolve imgproxy download failures.
2025-06-25 15:53:33 +00:00
Patrick Fic
c1e1dff7d2 IO-3281 resolve imgproxy download failures. 2025-06-25 08:51:41 -07:00
Dave Richer
f76eb7abf5 Merged in hotfix/IO-3280-Image-Selection-Bug (pull request #2374)
hotfix/IO-3280-Image-Selection-Bug - Fix Bug in image selection dialog
2025-06-24 18:43:29 +00:00
Patrick Fic
633d5668f0 IO-3279 Set usage report to 1 year. 2025-06-23 15:08:36 -07:00
19 changed files with 4261 additions and 4179 deletions

View File

@@ -25,6 +25,7 @@ import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility";
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
@@ -53,10 +54,10 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
const notification = useNotification(); const notification = useNotification();
const { const {
treatments: { Enhanced_Payroll } treatments: { Enhanced_Payroll, Imgproxy }
} = useSplitTreatments({ } = useSplitTreatments({
attributes: {}, attributes: {},
names: ["Enhanced_Payroll"], names: ["Enhanced_Payroll", "Imgproxy"],
splitKey: bodyshop.imexshopid splitKey: bodyshop.imexshopid
}); });
@@ -298,20 +299,39 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
}); });
}); });
} else { } else {
upload.forEach((u) => { //Check if using Imgproxy or cloudinary
handleUpload(
{ file: u.originFileObj }, if (Imgproxy.treatment === "on") {
{ upload.forEach((u) => {
bodyshop: bodyshop, handleUploadToImageProxy(
uploaded_by: currentUser.email, { file: u.originFileObj },
jobId: values.jobid, {
billId: billId, bodyshop: bodyshop,
tagsArray: null, uploaded_by: currentUser.email,
callback: null jobId: values.jobid,
}, billId: billId,
notification tagsArray: null,
); callback: null
}); },
notification
);
});
} else {
upload.forEach((u) => {
handleUpload(
{ file: u.originFileObj },
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null
},
notification
);
});
}
} }
} }
/////////////////////////// ///////////////////////////

View File

@@ -335,7 +335,11 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail })
</Card> </Card>
</Col> </Col>
<Col {...colSpan}> <Col {...colSpan}>
<Card style={{ height: "100%" }} title={t("jobs.labels.employeeassignments")}> <Card
style={{ height: "100%" }}
title=<span id="job-employee-assignments-title">{t("jobs.labels.employeeassignments")}</span>
id={"job-employee-assignments"}
>
<div> <div>
<JobEmployeeAssignments job={job} /> <JobEmployeeAssignments job={job} />
<Divider style={{ margin: ".5rem" }} /> <Divider style={{ margin: ".5rem" }} />

View File

@@ -46,32 +46,40 @@ export function JobsDocumentsImgproxyDownloadButton({ bodyshop, galleryImages, i
} }
function standardMediaDownload(bufferData) { function standardMediaDownload(bufferData) {
const a = document.createElement("a"); try {
const url = window.URL.createObjectURL(new Blob([bufferData])); const a = document.createElement("a");
a.href = url; const url = window.URL.createObjectURL(new Blob([bufferData]));
a.download = `${identifier || "documents"}.zip`; a.href = url;
a.click(); a.download = `${identifier || "documents"}.zip`;
a.click();
} catch (error) {
setLoading(false);
setDownload(null);
}
} }
const handleDownload = async () => { const handleDownload = async () => {
logImEXEvent("jobs_documents_download"); logImEXEvent("jobs_documents_download");
setLoading(true); setLoading(true);
const zipUrl = await axios({ try {
url: "/media/imgproxy/download", const response = await axios({
method: "POST", url: "/media/imgproxy/download",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) } method: "POST",
}); responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
const theDownloadedZip = await cleanAxios({ setLoading(false);
url: zipUrl.data.url, setDownload(null);
method: "GET",
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
standardMediaDownload(theDownloadedZip.data); // Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
} catch (error) {
setLoading(false);
setDownload(null);
// handle error (optional)
}
}; };
return ( return (

View File

@@ -98,7 +98,13 @@ function JobsDocumentsImgproxyComponent({
jobId={jobId} jobId={jobId}
totalSize={totalSize} totalSize={totalSize}
billId={billId} billId={billId}
callbackAfterUpload={billsCallback || fetchThumbnails || refetch} callbackAfterUpload={
billsCallback ||
function () {
isFunction(refetch) && refetch();
isFunction(fetchThumbnails) && fetchThumbnails();
}
}
ignoreSizeLimit={ignoreSizeLimit} ignoreSizeLimit={ignoreSizeLimit}
/> />
</Card> </Card>

View File

@@ -1,5 +1,5 @@
import { Col, Row } from "antd"; import { Col, Row } from "antd";
import React, { useEffect } from "react"; import { useEffect } from "react";
import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component"; import ScoreboardChart from "../scoreboard-chart/scoreboard-chart.component";
import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component"; import ScoreboardLastDays from "../scoreboard-last-days/scoreboard-last-days.component";
import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component"; import ScoreboardTargetsTable from "../scoreboard-targets-table/scoreboard-targets-table.component";
@@ -26,18 +26,18 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
start: dayjs().startOf("month"), start: dayjs().startOf("month"),
end: dayjs().endOf("month") end: dayjs().endOf("month")
}, },
pollInterval: 60000*5 pollInterval: 60000 * 5
}); });
const { data } = scoreboardSubscription; const { data } = scoreboardSubscription;
const client = useApolloClient(); const client = useApolloClient();
const scoreBoardlist = (data && data.scoreboard) || []; const scoreBoardlist = data?.scoreboard || [];
const sbEntriesByDate = {}; const sbEntriesByDate = {};
scoreBoardlist.forEach((i) => { scoreBoardlist.forEach((i) => {
const entryDate = i.date; const entryDate = i.date;
if (!!!sbEntriesByDate[entryDate]) { if (!sbEntriesByDate[entryDate]) {
sbEntriesByDate[entryDate] = []; sbEntriesByDate[entryDate] = [];
} }
sbEntriesByDate[entryDate].push(i); sbEntriesByDate[entryDate].push(i);
@@ -58,10 +58,9 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
}); });
appointments = data.appointments; appointments = data.appointments;
} }
dayjs.updateLocale(dayjs.locale(), {
dayjs.updateLocale("ca", {
workingWeekdays: translateSettingsToWorkingDays(bodyshop.workingdays), workingWeekdays: translateSettingsToWorkingDays(bodyshop.workingdays),
...(appointments ...(appointments?.length
? { ? {
holidays: appointments.map((h) => dayjs(h.start).format("MM-DD-YYYY")) holidays: appointments.map((h) => dayjs(h.start).format("MM-DD-YYYY"))
} }
@@ -78,11 +77,9 @@ export function ScoreboardDisplayComponent({ bodyshop }) {
<Col span={24}> <Col span={24}>
<ScoreboardTargetsTable scoreBoardlist={scoreBoardlist} /> <ScoreboardTargetsTable scoreBoardlist={scoreBoardlist} />
</Col> </Col>
<Col span={24}> <Col span={24}>
<ScoreboardLastDays sbEntriesByDate={sbEntriesByDate} /> <ScoreboardLastDays sbEntriesByDate={sbEntriesByDate} />
</Col> </Col>
<Col span={24}> <Col span={24}>
<ScoreboardChart sbEntriesByDate={sbEntriesByDate} /> <ScoreboardChart sbEntriesByDate={sbEntriesByDate} />
</Col> </Col>

View File

@@ -1,13 +1,15 @@
import { DeleteFilled } from "@ant-design/icons"; import { DeleteFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client"; import { useApolloClient, useMutation, useQuery } from "@apollo/client";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Button, Card, Form, Input, InputNumber, Select, Switch, Table } from "antd"; import { Button, Card, Form, Input, InputNumber, Select, Switch, Table } from "antd";
import { useForm } from "antd/es/form/Form"; import { useForm } from "antd/es/form/Form";
import dayjs from "../../utils/day"; import queryString from "query-string";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
import { import {
CHECK_EMPLOYEE_NUMBER, CHECK_EMPLOYEE_NUMBER,
@@ -20,19 +22,17 @@ import {
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import CiecaSelect from "../../utils/Ciecaselect"; import CiecaSelect from "../../utils/Ciecaselect";
import { DateFormatter } from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component"; import ShopEmployeeAddVacation from "./shop-employees-add-vacation.component";
import queryString from "query-string";
import { useSplitTreatments } from "@splitsoftware/splitio-react";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -83,7 +83,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
} }
} }
}) })
.then((r) => { .then(() => {
notification["success"]({ notification["success"]({
message: t("employees.successes.save") message: t("employees.successes.save")
}); });
@@ -120,13 +120,13 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
title: t("employees.fields.vacation.start"), title: t("employees.fields.vacation.start"),
dataIndex: "start", dataIndex: "start",
key: "start", key: "start",
render: (text, record) => <DateFormatter>{text}</DateFormatter> render: (text) => <DateFormatter>{text}</DateFormatter>
}, },
{ {
title: t("employees.fields.vacation.end"), title: t("employees.fields.vacation.end"),
dataIndex: "end", dataIndex: "end",
key: "end", key: "end",
render: (text, record) => <DateFormatter>{text}</DateFormatter> render: (text) => <DateFormatter>{text}</DateFormatter>
}, },
{ {
title: t("employees.fields.vacation.length"), title: t("employees.fields.vacation.length"),
@@ -210,7 +210,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
required: true required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({ () => ({
async validator(rule, value) { async validator(rule, value) {
if (value) { if (value) {
const response = await client.query({ const response = await client.query({
@@ -369,8 +369,9 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
add(); add();
}} }}
style={{ width: "100%" }} style={{ width: "100%" }}
id="add-employee-rate-button"
> >
{t("employees.actions.newrate")} <span id="new-employee-rate">{t("employees.actions.newrate")}</span>
</Button> </Button>
</Form.Item> </Form.Item>
</div> </div>
@@ -383,7 +384,7 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />} title={() => <ShopEmployeeAddVacation employee={data && data.employees_by_pk} />}
columns={columns} columns={columns}
rowKey={"id"} rowKey={"id"}
dataSource={data ? data.employees_by_pk.employee_vacations : []} dataSource={data?.employees_by_pk?.employee_vacations ?? []}
/> />
</Card> </Card>
); );

View File

@@ -14,6 +14,7 @@ import PhoneFormItem, { PhoneItemFormatterValidation } from "../form-items-forma
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component"; import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
// eslint-disable-next-line no-undef
const timeZonesList = Intl.supportedValuesOf("timeZone"); const timeZonesList = Intl.supportedValuesOf("timeZone");
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -143,236 +144,246 @@ export function ShopInfoGeneral({ form, bodyshop }) {
<InputNumber min={0} /> <InputNumber min={0} />
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<FeatureWrapper featureName="export" noauth={() => null}> <LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup">
<LayoutFormRow header={t("bodyshop.labels.accountingsetup")} id="accountingsetup"> {HasFeatureAccess({ featureName: "export", bodyshop }) && (
<Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}> <>
<Switch /> <Form.Item label={t("bodyshop.labels.qbo")} valuePropName="checked" name={["accountingconfig", "qbo"]}>
</Form.Item> <Switch />
{InstanceRenderManager({ </Form.Item>
imex: ( {InstanceRenderManager({
<Form.Item shouldUpdate noStyle> imex: (
{() => ( <Form.Item shouldUpdate noStyle>
{() => (
<Form.Item
label={t("bodyshop.labels.qbo_usa")}
shouldUpdate
valuePropName="checked"
name={["accountingconfig", "qbo_usa"]}
>
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} />
</Form.Item>
)}
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.labels.accountingtiers")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "tiers"]}
>
<Radio.Group>
<Radio value={2}>2</Radio>
<Radio value={3}>3</Radio>
</Radio.Group>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
return (
<Form.Item <Form.Item
label={t("bodyshop.labels.qbo_usa")} label={t("bodyshop.labels.2tiersetup")}
shouldUpdate shouldUpdate
valuePropName="checked" rules={[
name={["accountingconfig", "qbo_usa"]} {
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2
//message: t("general.validation.required"),
}
]}
name={["accountingconfig", "twotierpref"]}
> >
<Switch disabled={!form.getFieldValue(["accountingconfig", "qbo"])} /> <Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}>
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item> </Form.Item>
)} );
</Form.Item> }}
) </Form.Item>
})} <Form.Item
<Form.Item label={t("bodyshop.labels.qbo_departmentid")} name={["accountingconfig", "qbo_departmentid"]}> label={t("bodyshop.labels.printlater")}
<Input /> valuePropName="checked"
</Form.Item> name={["accountingconfig", "printlater"]}
<Form.Item >
label={t("bodyshop.labels.accountingtiers")} <Switch />
rules={[ </Form.Item>
{ <Form.Item
required: true label={t("bodyshop.labels.emaillater")}
//message: t("general.validation.required"), valuePropName="checked"
} name={["accountingconfig", "emaillater"]}
]} >
name={["accountingconfig", "tiers"]} <Switch />
> </Form.Item>
<Radio.Group> </>
<Radio value={2}>2</Radio> )}
<Radio value={3}>3</Radio> <Form.Item
</Radio.Group> label={t("bodyshop.fields.inhousevendorid")}
</Form.Item> name={"inhousevendorid"}
<Form.Item shouldUpdate> rules={[
{() => { {
return ( required: true
//message: t("general.validation.required"),
}
]}
>
<Input />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.default_adjustment_rate")}
name={"default_adjustment_rate"}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={2} />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id">
<Input />
</Form.Item>
)
})}
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id">
<Input />
</Form.Item>
{HasFeatureAccess({ featureName: "bills", bodyshop }) && (
<>
{InstanceRenderManager({
imex: (
<Form.Item <Form.Item
label={t("bodyshop.labels.2tiersetup")} label={t("bodyshop.fields.invoice_federal_tax_rate")}
shouldUpdate name={["bill_tax_rates", "federal_tax_rate"]}
rules={[ rules={[
{ {
required: form.getFieldValue(["accountingconfig", "tiers"]) === 2 required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
name={["accountingconfig", "twotierpref"]}
> >
<Radio.Group disabled={form.getFieldValue(["accountingconfig", "tiers"]) === 3}> <InputNumber />
<Radio value="name">{t("bodyshop.labels.2tiername")}</Radio>
<Radio value="source">{t("bodyshop.labels.2tiersource")}</Radio>
</Radio.Group>
</Form.Item> </Form.Item>
); )
}} })}
</Form.Item> <Form.Item
<Form.Item label={t("bodyshop.fields.invoice_state_tax_rate")}
label={t("bodyshop.labels.printlater")} name={["bill_tax_rates", "state_tax_rate"]}
valuePropName="checked" rules={[
name={["accountingconfig", "printlater"]} {
> required: true
<Switch /> //message: t("general.validation.required"),
</Form.Item> }
<Form.Item ]}
label={t("bodyshop.labels.emaillater")} >
valuePropName="checked" <InputNumber />
name={["accountingconfig", "emaillater"]} </Form.Item>
> <Form.Item
<Switch /> label={t("bodyshop.fields.invoice_local_tax_rate")}
</Form.Item> name={["bill_tax_rates", "local_tax_rate"]}
<Form.Item rules={[
label={t("bodyshop.fields.inhousevendorid")} {
name={"inhousevendorid"} required: true
rules={[ //message: t("general.validation.required"),
{ }
required: true ]}
//message: t("general.validation.required"), >
} <InputNumber />
]} </Form.Item>
> </>
<Input /> )}
</Form.Item> <Form.Item
<Form.Item name={["md_payment_types"]}
label={t("bodyshop.fields.default_adjustment_rate")} label={t("bodyshop.fields.md_payment_types")}
name={"default_adjustment_rate"} rules={[
rules={[ {
{ required: true,
required: true //message: t("general.validation.required"),
//message: t("general.validation.required"), type: "array"
} }
]} ]}
> >
<InputNumber min={0} precision={2} /> <Select mode="tags" />
</Form.Item> </Form.Item>
{InstanceRenderManager({ <Form.Item
imex: ( name={["md_categories"]}
<Form.Item label={t("bodyshop.fields.federal_tax_id")} name="federal_tax_id"> label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
{HasFeatureAccess({ featureName: "export", bodyshop }) && (
<>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input /> <Input />
</Form.Item> </Form.Item>
) )}
})} {ADPPayroll.treatment === "on" && (
<Form.Item label={t("bodyshop.fields.state_tax_id")} name="state_tax_id"> <Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input /> <Input />
</Form.Item>
{InstanceRenderManager({
imex: (
<Form.Item
label={t("bodyshop.fields.invoice_federal_tax_rate")}
name={["bill_tax_rates", "federal_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item> </Form.Item>
) )}
})} </>
<Form.Item )}
label={t("bodyshop.fields.invoice_state_tax_rate")} </LayoutFormRow>
name={["bill_tax_rates", "state_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
label={t("bodyshop.fields.invoice_local_tax_rate")}
name={["bill_tax_rates", "local_tax_rate"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber />
</Form.Item>
<Form.Item
name={["md_payment_types"]}
label={t("bodyshop.fields.md_payment_types")}
rules={[
{
required: true,
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item
name={["md_categories"]}
label={t("bodyshop.fields.md_categories")}
rules={[
{
//message: t("general.validation.required"),
type: "array"
}
]}
>
<Select mode="tags" />
</Form.Item>
<Form.Item name={["enforce_class"]} label={t("bodyshop.fields.enforce_class")} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField1"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 1 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField2"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 2 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["accountingconfig", "ReceivableCustomField3"]}
label={t("bodyshop.fields.ReceivableCustomField", { number: 3 })}
>
{ReceivableCustomFieldSelect}
</Form.Item>
<Form.Item
name={["md_classes"]}
label={t("bodyshop.fields.md_classes")}
rules={[
({ getFieldValue }) => {
return {
required: getFieldValue("enforce_class"),
//message: t("general.validation.required"),
type: "array"
};
}
]}
>
<Select mode="tags" />
</Form.Item>
{ClosingPeriod.treatment === "on" && (
<Form.Item
name={["accountingconfig", "ClosingPeriod"]}
label={t("bodyshop.fields.closingperiod")} //{t("reportcenter.labels.dates")}
>
<DatePicker.RangePicker format="MM/DD/YYYY" presets={DatePickerRanges} />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "companyCode"]} label={t("bodyshop.fields.companycode")}>
<Input />
</Form.Item>
)}
{ADPPayroll.treatment === "on" && (
<Form.Item name={["accountingconfig", "batchID"]} label={t("bodyshop.fields.batchid")}>
<Input />
</Form.Item>
)}
</LayoutFormRow>
</FeatureWrapper>
<FeatureWrapper featureName="scoreboard" noauth={() => null}> <FeatureWrapper featureName="scoreboard" noauth={() => null}>
<LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup"> <LayoutFormRow header={t("bodyshop.labels.scoreboardsetup")} id="scoreboardsetup">
<Form.Item <Form.Item
@@ -822,7 +833,11 @@ export function ShopInfoGeneral({ form, bodyshop }) {
}} }}
</Form.List> </Form.List>
</LayoutFormRow> </LayoutFormRow>
<LayoutFormRow grow header=<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span> id="insurancecos"> <LayoutFormRow
grow
header=<span id="insurancecos-header">{t("bodyshop.labels.insurancecos")}</span>
id="insurancecos"
>
<Form.List name={["md_ins_cos"]}> <Form.List name={["md_ins_cos"]}>
{(fields, { add, remove, move }) => { {(fields, { add, remove, move }) => {
return ( return (

View File

@@ -13,7 +13,7 @@ import { ColorPicker } from "./shop-info.rostatus.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
@@ -22,7 +22,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
return ( return (
<div> <div>
<LayoutFormRow> <LayoutFormRow id="shopinfo-scheduling">
<Form.Item <Form.Item
label={t("bodyshop.fields.appt_length")} label={t("bodyshop.fields.appt_length")}
name={"appt_length"} name={"appt_length"}
@@ -44,6 +44,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
id="schedule_start_time"
> >
<TimePicker disableSeconds={true} format="HH:mm" /> <TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item> </Form.Item>
@@ -56,6 +57,7 @@ export function ShopInfoSchedulingComponent({ form, bodyshop }) {
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
id="schedule_end_time"
> >
<TimePicker disableSeconds={true} format="HH:mm" /> <TimePicker disableSeconds={true} format="HH:mm" />
</Form.Item> </Form.Item>

View File

@@ -16,7 +16,7 @@ import {
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSplitTreatments } from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { SocketContext, INITIAL_NOTIFICATIONS } from "./useSocket.js"; import { INITIAL_NOTIFICATIONS, SocketContext } from "./useSocket.js";
/** /**
* Socket Provider - Scenario Notifications / Web Socket related items * Socket Provider - Scenario Notifications / Web Socket related items
@@ -157,7 +157,10 @@ const SocketProvider = ({ children, bodyshop, navigate, currentUser }) => {
auth: { token, bodyshopId: bodyshop.id }, auth: { token, bodyshopId: bodyshop.id },
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
reconnectionDelay: 2000, reconnectionDelay: 2000,
reconnectionDelayMax: 10000 reconnectionDelayMax: 60000,
randomizationFactor: 0.5,
transports: ["websocket", "polling"], // Add this to prefer WebSocket with polling fallback
rememberUpgrade: true
}); });
socketRef.current = socketInstance; socketRef.current = socketInstance;

View File

@@ -1,7 +1,7 @@
import Icon, { FieldTimeOutlined } from "@ant-design/icons"; import Icon, { FieldTimeOutlined } from "@ant-design/icons";
import { Card, Tabs } from "antd"; import { Card, Tabs } from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FaShieldAlt } from "react-icons/fa"; import { FaShieldAlt } from "react-icons/fa";
import { connect } from "react-redux"; import { connect } from "react-redux";
@@ -78,7 +78,7 @@ export function ScoreboardContainer({ setBreadcrumbs, setSelectedHeader }) {
<RbacWrapper action="scoreboard:view"> <RbacWrapper action="scoreboard:view">
<Tabs <Tabs
activeKey={tab || "sb"} activeKey={tab || "sb"}
destroyInactiveTabPane destroyOnHidden
onChange={(key) => { onChange={(key) => {
searchParams.tab = key; searchParams.tab = key;
history({ history({

12
package-lock.json generated
View File

@@ -63,7 +63,8 @@
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1",
"yazl": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "^9.28.0",
@@ -13057,6 +13058,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/yazl": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz",
"integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==",
"license": "MIT",
"dependencies": {
"buffer-crc32": "^1.0.0"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -70,7 +70,8 @@
"winston": "^3.17.0", "winston": "^3.17.0",
"winston-cloudwatch": "^6.3.0", "winston-cloudwatch": "^6.3.0",
"xml2js": "^0.6.2", "xml2js": "^0.6.2",
"xmlbuilder2": "^3.1.1" "xmlbuilder2": "^3.1.1",
"yazl": "^3.3.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.28.0", "@eslint/js": "^9.28.0",

View File

@@ -35,7 +35,7 @@ exports.default = async (req, res) => {
//Query the usage data. //Query the usage data.
const queryResults = await client.request(queries.STATUS_UPDATE, { const queryResults = await client.request(queries.STATUS_UPDATE, {
today: moment().startOf("day").subtract(7, "days"), today: moment().startOf("day").subtract(7, "days"),
period: moment().subtract(90, "days").startOf("day") period: moment().subtract(365, "days").startOf("day")
}); });
//Massage the data. //Massage the data.
@@ -66,7 +66,7 @@ exports.default = async (req, res) => {
Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers. Usage Report for ${moment().format("MM/DD/YYYY")} for Rome Online Customers.
Notes: Notes:
- Days Since Creation: The number of days since the shop was created. Only shops created in the last 90 days are included. - Days Since Creation: The number of days since the shop was created. Only shops created in the last 365 days are included.
- Updated values should be higher than created values. - Updated values should be higher than created values.
- Counts are inclusive of the last 7 days of data. - Counts are inclusive of the last 7 days of data.
`, `,

View File

@@ -19,7 +19,7 @@ async function JobCosting(req, res) {
const client = req.userGraphQLClient; const client = req.userGraphQLClient;
//Uncomment for further testing //Uncomment for further testing
// logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null); logger.log("job-costing-start", "DEBUG", req.user.email, jobid, null);
try { try {
const resp = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_JOB_COSTING_DETAILS, { const resp = await client.setHeaders({ Authorization: BearerToken }).request(queries.QUERY_JOB_COSTING_DETAILS, {
@@ -47,9 +47,9 @@ async function JobCostingMulti(req, res) {
const client = req.userGraphQLClient; const client = req.userGraphQLClient;
//Uncomment for further testing //Uncomment for further testing
// logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, { logger.log("job-costing-multi-start", "DEBUG", req?.user?.email, null, {
// jobids jobids
// }); });
try { try {
const resp = await client const resp = await client
@@ -589,7 +589,7 @@ function GenerateCostingData(job) {
amount: Math.round((job.storage_payable || 0) * 100) amount: Math.round((job.storage_payable || 0) * 100)
}); });
} }
//Is it a DMS Setup? //Is it a DMS Setup?
const selectedDmsAllocationConfig = const selectedDmsAllocationConfig =
(job.bodyshop.md_responsibility_centers.dms_defaults && (job.bodyshop.md_responsibility_centers.dms_defaults &&

View File

@@ -8,6 +8,7 @@ const getLifecycleStatusColor = require("../utils/getLifecycleStatusColor");
const jobLifecycle = async (req, res) => { const jobLifecycle = async (req, res) => {
// Grab the jobids and statuses from the request body // Grab the jobids and statuses from the request body
const { jobids, statuses } = req.body; const { jobids, statuses } = req.body;
const { logger } = req;
if (!jobids) { if (!jobids) {
return res.status(400).json({ return res.status(400).json({
@@ -16,102 +17,118 @@ const jobLifecycle = async (req, res) => {
} }
const jobIDs = _.isArray(jobids) ? jobids : [jobids]; const jobIDs = _.isArray(jobids) ? jobids : [jobids];
const client = req.userGraphQLClient;
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs });
const transitions = resp.transitions; logger.log("job-lifecycle-start", "DEBUG", req?.user?.email, null, {
jobids: jobIDs
});
try {
const client = req.userGraphQLClient;
const resp = await client.request(queries.QUERY_TRANSITIONS_BY_JOBID, { jobids: jobIDs });
const transitions = resp.transitions;
if (!transitions) {
return res.status(200).json({
jobIDs,
transitions: []
});
}
const transitionsByJobId = _.groupBy(resp.transitions, "jobid");
const groupedTransitions = {};
const allDurations = [];
for (let jobId in transitionsByJobId) {
let lifecycle = transitionsByJobId[jobId].map((transition) => {
transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A";
transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A";
if (transition.duration) {
transition.duration_seconds = Math.round(transition.duration / 1000);
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
let duration = moment.duration(transition.duration);
transition.duration_readable = durationToHumanReadable(duration);
} else {
transition.duration_seconds = 0;
transition.duration_minutes = 0;
transition.duration_readable = "N/A";
}
return transition;
});
const durations = calculateStatusDuration(lifecycle, statuses);
groupedTransitions[jobId] = {
lifecycle,
durations
};
if (durations?.summations) {
allDurations.push(durations.summations);
}
}
const finalSummations = [];
const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status");
const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => {
acc[status] = flatGroupedAllDurations[status].length;
return acc;
}, {});
// Calculate total value of all statuses
const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => {
return total + statusArr.reduce((acc, curr) => acc + curr.value, 0);
}, 0);
Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value));
const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0;
const color = getLifecycleStatusColor(status);
const roundedPercentage = `${Math.round(percentage)}%`;
const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0;
const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue));
finalSummations.push({
status,
value,
humanReadable,
percentage,
color,
roundedPercentage,
averageValue,
averageHumanReadable
});
});
if (!transitions) {
return res.status(200).json({ return res.status(200).json({
jobIDs, jobIDs,
transitions: [] transition: groupedTransitions,
}); durations: {
} jobs: jobIDs.length,
summations: finalSummations,
const transitionsByJobId = _.groupBy(resp.transitions, "jobid"); totalStatuses: finalSummations.length,
total: finalTotal,
const groupedTransitions = {}; statusCounts: finalStatusCounts,
const allDurations = []; humanReadable: durationToHumanReadable(moment.duration(finalTotal)),
averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0,
for (let jobId in transitionsByJobId) { averageHumanReadable:
let lifecycle = transitionsByJobId[jobId].map((transition) => { _.size(jobIDs) > 0
transition.start_readable = transition.start ? moment(transition.start).fromNow() : "N/A"; ? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length))
transition.end_readable = transition.end ? moment(transition.end).fromNow() : "N/A"; : durationToHumanReadable(moment.duration(0))
if (transition.duration) {
transition.duration_seconds = Math.round(transition.duration / 1000);
transition.duration_minutes = Math.round(transition.duration_seconds / 60);
let duration = moment.duration(transition.duration);
transition.duration_readable = durationToHumanReadable(duration);
} else {
transition.duration_seconds = 0;
transition.duration_minutes = 0;
transition.duration_readable = "N/A";
} }
return transition;
}); });
} catch (error) {
const durations = calculateStatusDuration(lifecycle, statuses); logger.log("job-lifecycle-error", "ERROR", req?.user?.email, null, {
jobids: jobIDs,
groupedTransitions[jobId] = { statuses: statuses ? JSON.stringify(statuses) : "N/A",
lifecycle, error: error.message
durations });
}; return res.status(500).json({
error: "Internal server error"
if (durations?.summations) { });
allDurations.push(durations.summations);
}
} }
const finalSummations = [];
const flatGroupedAllDurations = _.groupBy(allDurations.flat(), "status");
const finalStatusCounts = Object.keys(flatGroupedAllDurations).reduce((acc, status) => {
acc[status] = flatGroupedAllDurations[status].length;
return acc;
}, {});
// Calculate total value of all statuses
const finalTotal = Object.values(flatGroupedAllDurations).reduce((total, statusArr) => {
return total + statusArr.reduce((acc, curr) => acc + curr.value, 0);
}, 0);
Object.keys(flatGroupedAllDurations).forEach((status) => {
const value = flatGroupedAllDurations[status].reduce((acc, curr) => acc + curr.value, 0);
const humanReadable = durationToHumanReadable(moment.duration(value));
const percentage = finalTotal > 0 ? (value / finalTotal) * 100 : 0;
const color = getLifecycleStatusColor(status);
const roundedPercentage = `${Math.round(percentage)}%`;
const averageValue = _.size(jobIDs) > 0 ? value / jobIDs.length : 0;
const averageHumanReadable = durationToHumanReadable(moment.duration(averageValue));
finalSummations.push({
status,
value,
humanReadable,
percentage,
color,
roundedPercentage,
averageValue,
averageHumanReadable
});
});
return res.status(200).json({
jobIDs,
transition: groupedTransitions,
durations: {
jobs: jobIDs.length,
summations: finalSummations,
totalStatuses: finalSummations.length,
total: finalTotal,
statusCounts: finalStatusCounts,
humanReadable: durationToHumanReadable(moment.duration(finalTotal)),
averageValue: _.size(jobIDs) > 0 ? finalTotal / jobIDs.length : 0,
averageHumanReadable:
_.size(jobIDs) > 0
? durationToHumanReadable(moment.duration(finalTotal / jobIDs.length))
: durationToHumanReadable(moment.duration(0))
}
});
}; };
module.exports = jobLifecycle; module.exports = jobLifecycle;

View File

@@ -381,7 +381,7 @@ async function CalculateRatesTotals({ job, client }) {
if (item.mod_lbr_ty) { if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead. //Check to see if it has 0 hours and a price instead.
if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") { if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) {
//Scenario where SGI may pay out hours using a part price. //Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) { if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero(); ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();

View File

@@ -314,7 +314,8 @@ function CalculateRatesTotals(ratesList) {
if (item.mod_lbr_ty) { if (item.mod_lbr_ty) {
//Check to see if it has 0 hours and a price instead. //Check to see if it has 0 hours and a price instead.
if (item.mod_lb_hrs === 0 && item.act_price > 0 && item.lbr_op === "OP14") { //Extend for when there are hours and a price.
if (item.lbr_op === "OP14" && item.act_price > 0 && (!item.part_type || item.mod_lb_hrs === 0)) {
//Scenario where SGI may pay out hours using a part price. //Scenario where SGI may pay out hours using a part price.
if (!ret[item.mod_lbr_ty.toLowerCase()].total) { if (!ret[item.mod_lbr_ty.toLowerCase()].total) {
ret[item.mod_lbr_ty.toLowerCase()].total = Dinero(); ret[item.mod_lbr_ty.toLowerCase()].total = Dinero();

View File

@@ -20,6 +20,7 @@ const {
GET_DOCUMENTS_BY_IDS, GET_DOCUMENTS_BY_IDS,
DELETE_MEDIA_DOCUMENTS DELETE_MEDIA_DOCUMENTS
} = require("../graphql-client/queries"); } = require("../graphql-client/queries");
const yazl = require("yazl");
const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN. const imgproxyBaseUrl = process.env.IMGPROXY_BASE_URL; // `https://u4gzpp5wm437dnm75qa42tvza40fguqr.lambda-url.ca-central-1.on.aws` //Direct Lambda function access to bypass CDN.
const imgproxySalt = process.env.IMGPROXY_SALT; const imgproxySalt = process.env.IMGPROXY_SALT;
@@ -102,13 +103,7 @@ const getThumbnailUrls = async (req, res) => {
//<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with un-encoded/un-hashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path> //<Cloudfront_to_lambda>/<hmac with SHA of entire request URI path (with base64 encoded URL if needed), beginning with un-encoded/un-hashed Salt>/<remainder of url - resize params >/< base 64 URL encoded to image path>
//When working with documents from Cloudinary, the URL does not include the extension. //When working with documents from Cloudinary, the URL does not include the extension.
let key; let key = keyStandardize(document)
if (/\.[^/.]+$/.test(document.key)) {
key = document.key;
} else {
key = `${document.key}.${document.extension.toLowerCase()}`;
}
// Build the S3 path to the object. // Build the S3 path to the object.
const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`; const fullS3Path = `s3://${imgproxyDestinationBucket}/${key}`;
const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path); const base64UrlEncodedKeyString = base64UrlEncode(fullS3Path);
@@ -168,78 +163,73 @@ const getThumbnailUrls = async (req, res) => {
* @returns {Promise<*>} * @returns {Promise<*>}
*/ */
const downloadFiles = async (req, res) => { const downloadFiles = async (req, res) => {
//Given a series of document IDs or keys, generate a file (or a link) to download all images in bulk
const { jobId, billid, documentids } = req.body; const { jobId, billid, documentids } = req.body;
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids });
const client = req.userGraphQLClient;
let data;
try { try {
logger.log("imgproxy-download", "DEBUG", req.user?.email, jobId, { billid, jobId, documentids }); data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
//Delayed as the key structure may change slightly from what it is currently and will require evaluating mobile components.
const client = req.userGraphQLClient;
//Query for the keys of the document IDs
const data = await client.request(GET_DOCUMENTS_BY_IDS, { documentIds: documentids });
//Using the Keys, get all the S3 links, zip them, and send back to the client.
const s3client = new S3Client({ region: InstanceRegion() });
const archiveStream = archiver("zip");
archiveStream.on("error", (error) => {
console.error("Archival encountered an error:", error);
throw new Error(error);
});
const passThrough = new stream.PassThrough();
archiveStream.pipe(passThrough);
for (const key of data.documents.map((d) => d.key)) {
const response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
archiveStream.append(response.Body, { name: path.basename(key) });
}
await archiveStream.finalize();
const archiveKey = `archives/${jobId || "na"}/archive-${new Date().toISOString()}.zip`;
const parallelUploads3 = new Upload({
client: s3client,
queueSize: 4, // optional concurrency configuration
leavePartsOnError: false, // optional manually handle dropped parts
params: { Bucket: imgproxyDestinationBucket, Key: archiveKey, Body: passThrough }
});
// Disabled progress logging for upload, uncomment if needed
// parallelUploads3.on("httpUploadProgress", (progress) => {
// console.log(progress);
// });
await parallelUploads3.done();
//Generate the presigned URL to download it.
const presignedUrl = await getSignedUrl(
s3client,
new GetObjectCommand({ Bucket: imgproxyDestinationBucket, Key: archiveKey }),
{ expiresIn: 360 }
);
return res.json({ success: true, url: presignedUrl });
//Iterate over them, build the link based on the media type, and return the array.
} catch (error) { } catch (error) {
logger.log("imgproxy-thumbnails-error", "ERROR", req.user?.email, jobId, { logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, {
jobId, jobId,
billid, billid,
message: error.message, message: error.message,
stack: error.stack stack: error.stack
}); });
return res.status(400).json({ message: error.message });
}
return res.status(400).json({ message: error.message, stack: error.stack }); const s3client = new S3Client({ region: InstanceRegion() });
const zipfile = new yazl.ZipFile();
const filename = `archive-${jobId || "na"}-${new Date().toISOString().replace(/[:.]/g, "-")}.zip`;
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
// Handle zipfile stream errors
zipfile.outputStream.on("error", (err) => {
logger.log("imgproxy-download-zipstream-error", "ERROR", req.user?.email, jobId, { message: err.message, stack: err.stack });
// Cannot send another response here, just destroy the connection
res.destroy(err);
});
zipfile.outputStream.pipe(res);
try {
for (const doc of data.documents) {
let key = keyStandardize(doc)
let response;
try {
response = await s3client.send(
new GetObjectCommand({
Bucket: imgproxyDestinationBucket,
Key: key
})
);
} catch (err) {
logger.log("imgproxy-download-s3-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack });
// Optionally, skip this file or add a placeholder file in the zip
continue;
}
// Attach error handler to S3 stream
response.Body.on("error", (err) => {
logger.log("imgproxy-download-s3stream-error", "ERROR", req.user?.email, jobId, { key, message: err.message, stack: err.stack });
res.destroy(err);
});
zipfile.addReadStream(response.Body, path.basename(key));
}
zipfile.end();
} catch (error) {
logger.log("imgproxy-download-error", "ERROR", req.user?.email, jobId, {
jobId,
billid,
message: error.message,
stack: error.stack
});
// Cannot send another response here, just destroy the connection
res.destroy(error);
} }
}; };
@@ -392,6 +382,15 @@ const moveFiles = async (req, res) => {
} }
}; };
const keyStandardize = (doc) => {
if (/\.[^/.]+$/.test(doc.key)) {
return doc.key;
} else {
return `${doc.key}.${doc.extension.toLowerCase()}`;
}
};
module.exports = { module.exports = {
generateSignedUploadUrls, generateSignedUploadUrls,
getThumbnailUrls, getThumbnailUrls,