Compare commits

..

1 Commits

Author SHA1 Message Date
Allan Carr
ddc6141480 IO-3452 Documents Adjustments
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-11-27 13:15:26 -08:00
11 changed files with 205 additions and 178 deletions

View File

@@ -35,16 +35,14 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
...galleryImages.other.filter((image) => image.isSelected)
];
function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
return {
downloaded: progressEvent.loaded || 0,
speed: (progressEvent.loaded || 0) - ((currentDownloadState && currentDownloadState.downloaded) || 0)
};
});
}
const downloadProgress = ({ loaded }) => {
setDownload((currentDownloadState) => ({
downloaded: loaded ?? 0,
speed: (loaded ?? 0) - (currentDownloadState?.downloaded ?? 0)
}));
};
function standardMediaDownload(bufferData) {
const standardMediaDownload = (bufferData) => {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
@@ -55,29 +53,26 @@ export function JobsDocumentsImgproxyDownloadButton({ galleryImages, identifier,
setLoading(false);
setDownload(null);
}
}
};
const handleDownload = async () => {
logImEXEvent("jobs_documents_download");
setLoading(true);
try {
const response = await axios({
const { data } = await axios({
url: "/media/imgproxy/download",
method: "POST",
responseType: "blob",
data: { jobId, documentids: imagesToDownload.map((_) => _.id) },
onDownloadProgress: downloadProgress
});
setLoading(false);
setDownload(null);
// Use the response data (Blob) to trigger download
standardMediaDownload(response.data);
standardMediaDownload(data);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
// handle error (optional)
}
};

View File

@@ -76,14 +76,14 @@ function JobsDocumentsImgproxyComponent({
<SyncOutlined />
</Button>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} jobId={jobId} />
<JobsDocumentsDeleteButton
galleryImages={galleryImages}
deletionCallback={billsCallback || fetchThumbnails || refetch}
/>
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />
)}
</Space>
</Col>
{!hasMediaAccess && (

View File

@@ -67,7 +67,7 @@ export default function JobsDocumentsImgproxyDeleteButton({ galleryImages, delet
okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")}
>
<Button disabled={imagesToDelete.length < 1} loading={loading}>
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm>

View File

@@ -107,8 +107,8 @@ export function JobsDocumentsLocalGallery({
<a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button>
</a>
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsDocumentsLocalGallerySelectAllComponent jobid={job.id} />
<JobsDocumentsLocalGalleryReassign jobid={job.id} />
<JobsLocalGalleryDownloadButton job={job} />
<JobsDocumentsLocalDeleteButton jobid={job.id} />
</Space>

View File

@@ -28,6 +28,8 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
const [loading, setLoading] = useState(false);
const imagesToDelete = (allMedia?.[jobid] || []).filter((i) => i.isSelected);
const handleDelete = async () => {
logImEXEvent("job_documents_delete");
setLoading(true);
@@ -36,7 +38,7 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
`${bodyshop.localmediaserverhttp}/jobs/delete`,
{
jobid: jobid,
files: (allMedia?.[jobid] || []).filter((i) => i.isSelected).map((i) => i.filename)
files: imagesToDelete.map((i) => i.filename)
},
{ headers: { ims_token: bodyshop.localmediatoken } }
);
@@ -60,14 +62,17 @@ export function JobsDocumentsLocalDeleteButton({ bodyshop, getJobMedia, allMedia
return (
<Popconfirm
disabled={imagesToDelete.length < 1}
icon={<QuestionCircleOutlined style={{ color: "red" }} />}
onConfirm={handleDelete}
title={t("documents.labels.confirmdelete")}
okText={t("general.actions.delete")}
okButtonProps={{ type: "danger" }}
okButtonProps={{ danger: true }}
cancelText={t("general.actions.cancel")}
>
<Button loading={loading}>{t("documents.actions.delete")}</Button>
<Button danger disabled={imagesToDelete.length < 1} loading={loading}>
{t("documents.actions.delete")}
</Button>
</Popconfirm>
);
}

View File

@@ -1,8 +1,8 @@
import { Button } from "antd";
import { Button, Space } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import cleanAxios from "../../utils/CleanAxios";
import formatBytes from "../../utils/formatbytes";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectAllMedia } from "../../redux/media/media.selectors";
@@ -19,45 +19,63 @@ export default connect(mapStateToProps, mapDispatchToProps)(JobsLocalGalleryDown
export function JobsLocalGalleryDownloadButton({ bodyshop, allMedia, job }) {
const { t } = useTranslation();
const [download, setDownload] = useState(null);
const [loading, setLoading] = useState(false);
const [download, setDownload] = useState(false);
function downloadProgress(progressEvent) {
setDownload((currentDownloadState) => {
return {
downloaded: progressEvent.loaded || 0,
speed: (progressEvent.loaded || 0) - (currentDownloadState?.downloaded || 0)
};
});
}
const imagesToDownload = (allMedia?.[job.id] || []).filter((i) => i.isSelected);
const downloadProgress = ({ loaded }) => {
setDownload((currentDownloadState) => ({
downloaded: loaded || 0,
speed: (loaded || 0) - (currentDownloadState?.downloaded || 0)
}));
};
const standardMediaDownload = (bufferData, filename) => {
try {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
} catch {
setLoading(false);
setDownload(null);
}
};
const handleDownload = async () => {
const theDownloadedZip = await cleanAxios.post(
`${bodyshop.localmediaserverhttp}/jobs/download`,
{
jobid: job.id,
files: (allMedia?.[job.id] || []).filter((i) => i.isSelected).map((i) => i.filename)
},
{
headers: { ims_token: bodyshop.localmediatoken },
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
}
);
setDownload(null);
standardMediaDownload(theDownloadedZip.data, job.ro_number);
const { localmediaserverhttp, localmediatoken } = bodyshop;
const { id, ro_number } = job;
setLoading(true);
try {
const response = await cleanAxios.post(
`${localmediaserverhttp}/jobs/download`,
{
jobid: id,
files: imagesToDownload.map((i) => i.filename)
},
{
headers: { ims_token: localmediatoken },
responseType: "arraybuffer",
onDownloadProgress: downloadProgress
}
);
standardMediaDownload(response.data, ro_number);
} catch {
// handle error (optional)
} finally {
setLoading(false);
setDownload(null);
}
};
return (
<Button loading={!!download} onClick={handleDownload}>
{t("documents.actions.download")}
<Button disabled={imagesToDownload < 1} loading={download || loading} onClick={handleDownload}>
<Space>
<span>{t("documents.actions.download")}</span>
{download && <span>{`(${formatBytes(download.downloaded)} @ ${formatBytes(download.speed)} / second)`}</span>}
</Space>
</Button>
);
}
function standardMediaDownload(bufferData, filename) {
const a = document.createElement("a");
const url = window.URL.createObjectURL(new Blob([bufferData]));
a.href = url;
a.download = `${filename}.zip`;
a.click();
}

View File

@@ -16,7 +16,7 @@ const mapDispatchToProps = () => ({
export function TechHeader({ technician }) {
const { t } = useTranslation();
return (
<Header style={{ textAlign: "center", height: "auto", overflow: "visible" }}>
<Header style={{ textAlign: "center" }}>
<Typography.Title style={{ color: "#fff" }}>
{technician
? t("tech.labels.loggedin", {

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery } from "@apollo/client";
import { Button, Card, Form, InputNumber, Popover, Select, Space } from "antd";
import { Button, Card, Col, Form, InputNumber, Popover, Row, Select } from "antd";
import axios from "axios";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -124,97 +124,71 @@ export function TechClockOffButton({
cost_center: isShiftTicket ? "timetickets.labels.shift" : technician ? technician.cost_center : null
}}
>
<Space direction="vertical">
{!isShiftTicket ? (
<div>
<Form.Item
label={t("timetickets.fields.actualhrs")}
name="actualhrs"
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
{
required: true
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
validator(rule, value) {
if (!bodyshop.tt_enforce_hours_for_tech_console) {
return Promise.resolve();
}
if (!value || getFieldValue("cost_center") === null || !lineTicketData)
return Promise.resolve();
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const fieldTypeToCheck =
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
const costCenterDiff =
Math.round(
totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
?.difference * 10
) / 10;
if (value > costCenterDiff)
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
else {
return Promise.resolve();
}
<Row gutter={[16, 16]}>
<Col span={!isShiftTicket ? 8 : 24}>
{!isShiftTicket ? (
<div>
<Form.Item
label={t("timetickets.fields.actualhrs")}
name="actualhrs"
rules={[
{
required: true
//message: t("general.validation.required"),
}
})
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</div>
) : null}
<Form.Item
name="cost_center"
label={t("timetickets.fields.cost_center")}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select disabled={isShiftTicket}>
{isShiftTicket ? (
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select>
</Form.Item>
{isShiftTicket ? (
<div></div>
) : (
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
<Form.Item
label={t("timetickets.fields.productivehrs")}
name="productivehrs"
rules={[
{
required: true
//message: t("general.validation.required"),
},
({ getFieldValue }) => ({
validator(rule, value) {
if (!bodyshop.tt_enforce_hours_for_tech_console) {
return Promise.resolve();
}
if (!value || getFieldValue("cost_center") === null || !lineTicketData)
return Promise.resolve();
//Check the cost center,
const totals = CalculateAllocationsTotals(
bodyshop,
lineTicketData.joblines,
lineTicketData.timetickets,
lineTicketData.jobs_by_pk.lbr_adjustments
);
const fieldTypeToCheck =
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber ? "mod_lbr_ty" : "cost_center";
const costCenterDiff =
Math.round(
totals.find((total) => total[fieldTypeToCheck] === getFieldValue("cost_center"))
?.difference * 10
) / 10;
if (value > costCenterDiff)
return Promise.reject(t("timetickets.validation.hoursenteredmorethanavailable"));
else {
return Promise.resolve();
}
}
})
]}
>
<InputNumber min={0} precision={1} />
</Form.Item>
</div>
) : null}
<Form.Item
name="status"
label={t("jobs.fields.status")}
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
name="cost_center"
label={t("timetickets.fields.cost_center")}
rules={[
{
required: true
@@ -222,32 +196,67 @@ export function TechClockOffButton({
}
]}
>
<Select>
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
<Select disabled={isShiftTicket}>
{isShiftTicket ? (
<Select.Option value="timetickets.labels.shift">{t("timetickets.labels.shift")}</Select.Option>
) : (
emps &&
emps.rates.map((item) => (
<Select.Option key={item.cost_center}>
{item.cost_center === "timetickets.labels.shift"
? t(item.cost_center)
: bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
: item.cost_center}
</Select.Option>
))
)}
</Select>
</Form.Item>
)}
<Button type="primary" htmlType="submit" loading={loading}>
{t("general.actions.save")}
</Button>
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
{isShiftTicket ? (
<div></div>
) : (
<Form.Item
name="status"
label={t("jobs.fields.status")}
initialValue={lineTicketData && lineTicketData.jobs_by_pk.status}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<Select>
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
<Select.Option key={item}></Select.Option>
))}
</Select>
</Form.Item>
)}
<Button type="primary" htmlType="submit" loading={loading}>
{t("general.actions.save")}
</Button>
<TechJobClockoutDelete completedCallback={completedCallback} timeTicketId={timeTicketId} />
</Col>
{!isShiftTicket && (
<LaborAllocationContainer jobid={jobId || null} loading={queryLoading} lineTicketData={lineTicketData} />
<Col span={16}>
<LaborAllocationContainer
jobid={jobId || null}
loading={queryLoading}
lineTicketData={lineTicketData}
/>
</Col>
)}
</Space>
</Row>
</Form>
</div>
</Card>
);
return (
<Popover
content={<div style={{ maxHeight: "75vh", overflowY: "auto" }}>{overlay}</div>}
trigger="click"
getPopupContainer={() => document.querySelector('#time-ticket-modal')}
>
<Popover content={overlay} trigger="click">
<Button loading={loading} {...otherBtnProps}>
{t("timetickets.actions.clockout")}
</Button>

View File

@@ -234,10 +234,11 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = {
ro_number: crypto.createHash("md5").update(job.id, "utf8").digest("hex"),
v_vin: job.v_vin || "",
v_year: (() => {
const y = parseInt(job.v_model_yr);
return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
})(),
v_year: job.v_model_yr
? parseInt(job.v_model_yr.match(/\d/g))
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10)
: ""
: "",
v_make: job.v_makedesc || "",
v_model: job.v_model || "",

View File

@@ -286,10 +286,11 @@ const CreateRepairOrderTag = (job, errorCallback) => {
const ret = {
ro_number: crypto.createHash("md5").update(job.ro_number, "utf8").digest("hex"),
v_vin: job.v_vin || "",
v_year: (() => {
const y = parseInt(job.v_model_yr);
return isNaN(y) ? null : y < 100 ? y + (y >= (new Date().getFullYear() + 1) % 100 ? 1900 : 2000) : y;
})(),
v_year: job.v_model_yr
? parseInt(job.v_model_yr.match(/\d/g))
? parseInt(job.v_model_yr.match(/\d/g).join(""), 10)
: ""
: "",
v_make: job.v_make_desc || "",
v_model: job.v_model_desc || "",

View File

@@ -55,9 +55,7 @@ exports.default = async (req, res) => {
"patrick.fic@convenient-brands.com",
"bradley.rhoades@convenient-brands.com",
"jrome@rometech.com",
"ivana@imexsystems.ca",
"support@imexsystems.ca",
"sarah@rometech.com"
"ivana@imexsystems.ca"
],
subject: `RO Usage Report - ${moment().format("MM/DD/YYYY")}`,
text: `