IO-3515 resolve issues on search selects not updating, improve confidence scoring.

This commit is contained in:
Patrick Fic
2026-02-19 12:22:35 -08:00
parent 5d53d09af9
commit ae1408012f
11 changed files with 410 additions and 26552 deletions

View File

@@ -1,11 +1,13 @@
import { Button } from "antd";
import { Button, Tag, Modal, Typography } from "antd";
import axios from "axios";
import { useState } from "react";
import { FaWandMagicSparkles } from "react-icons/fa6";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext";
import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { FaWandMagicSparkles } from "react-icons/fa6";
import { useApolloClient } from "@apollo/client/react";
const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal,
@@ -24,11 +26,31 @@ function BillEnterAiScan({
setIsAiScan
}) {
const notification = useNotification();
const [showBetaModal, setShowBetaModal] = useState(false);
const BETA_ACCEPTANCE_KEY = "ai_scan_beta_acceptance";
const client = useApolloClient();
const handleBetaAcceptance = () => {
localStorage.setItem(BETA_ACCEPTANCE_KEY, "true");
setShowBetaModal(false);
// Trigger the file input after acceptance
fileInputRef.current?.click();
};
const checkBetaAcceptance = () => {
const hasAccepted = localStorage.getItem(BETA_ACCEPTANCE_KEY);
if (hasAccepted) {
// User has already accepted, proceed with file selection
fileInputRef.current?.click();
} else {
// Show beta modal
setShowBetaModal(true);
}
};
// Polling function for multipage PDF status
const pollJobStatus = async (jobId) => {
const pollJobStatus = async (textractJobId) => {
try {
const { data } = await axios.get(`/ai/bill-ocr/status/${jobId}`);
const { data } = await axios.get(`/ai/bill-ocr/status/${textractJobId}`);
if (data.status === "COMPLETED") {
// Stop polling
@@ -41,6 +63,7 @@ function BillEnterAiScan({
// Update form with the extracted data
if (data.data && data.data.billForm) {
form.setFieldsValue(data.data.billForm);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: "AI Scan Complete",
message: "Invoice data has been extracted successfully"
@@ -82,69 +105,92 @@ function BillEnterAiScan({
type="file"
accept="image/*,application/pdf"
style={{ display: "none" }}
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
setScanLoading(true);
setIsAiScan(true);
const formdata = new FormData();
formdata.append("billScan", file);
formdata.append("jobid", billEnterModal.context.job.id);
formdata.append("jobid", billEnterModal.context.job?.id);
formdata.append("bodyshopid", bodyshop.id);
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
//formdata.append("skipTextract", "true"); // For testing purposes
axios
.post("/ai/bill-ocr", formdata)
.then(({ data, status }) => {
if (status === 202) {
// Multipage PDF - start polling
notification.info({
title: "Processing Invoice",
message: "This is a multipage document. Processing may take a few moments..."
});
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
setPollingIntervalRef(
setInterval(() => {
pollJobStatus(data.jobId);
}, 3000)
);
try {
const { data, status } = await axios.post("/ai/bill-ocr", formdata);
// Initial poll
pollJobStatus(data.jobId);
} else if (status === 200) {
// Single page - immediate response
setScanLoading(false);
form.setFieldsValue(data.data.billForm);
notification.success({
title: "AI Scan Complete",
message: "Invoice data has been extracted successfully"
});
}
})
.catch((error) => {
setScanLoading(false);
notification.error({
title: "AI Scan Failed",
message: error.response?.data?.message || error.message || "Failed to process invoice"
if (status === 202) {
// Multipage PDF - start polling
notification.info({
title: "Processing Invoice",
message: "This is a multipage document. Processing may take a few moments..."
});
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
setPollingIntervalRef(
setInterval(() => {
pollJobStatus(data.textractJobId);
}, 3000)
);
// Initial poll
pollJobStatus(data.textractJobId);
} else if (status === 200) {
// Single page - immediate response
setScanLoading(false);
form.setFieldsValue(data.data.billForm);
await form.validateFields(["billlines"], { recursive: true });
notification.success({
title: "AI Scan Complete",
message: "Invoice data has been extracted successfully"
});
}
} catch (error) {
setScanLoading(false);
notification.error({
title: "AI Scan Failed",
message: error.response?.data?.message || error.message || "Failed to process invoice"
});
}
}
// Reset the input so the same file can be selected again
e.target.value = "";
}}
/>
<Button
onClick={() => {
fileInputRef.current?.click();
}}
icon={<FaWandMagicSparkles />}
loading={scanLoading}
disabled={scanLoading}
>
<Button onClick={checkBetaAcceptance} icon={<FaWandMagicSparkles />} loading={scanLoading} disabled={scanLoading}>
{scanLoading ? "Processing Invoice..." : "AI Scan"}
<Tag color="red">BETA</Tag>
</Button>
<Modal
title="AI Scan Beta Disclaimer"
open={showBetaModal}
onOk={handleBetaAcceptance}
onCancel={() => setShowBetaModal(false)}
okText="Accept and Continue"
cancelText="Cancel"
>
<Typography.Title level={2}>AI Usage Disclaimer</Typography.Title>
<Typography.Text>
This AI scanning feature is currently in <strong>beta</strong>. While it can accelerate data entry, you{" "}
<strong>must carefully review all extracted results</strong> for accuracy.
</Typography.Text>
<Typography.Text>The AI may make mistakes or miss information. Always verify:</Typography.Text>
<ul>
<li>All line items and quantities</li>
<li>Prices and totals</li>
<li>Part numbers and descriptions</li>
<li>Any other critical invoice details</li>
</ul>
<Typography.Text>
By continuing, you acknowledge that you will review and verify all AI-generated data before posting.
</Typography.Text>
</Modal>
</>
);
}

View File

@@ -8,12 +8,15 @@ import { MdOpenInNew } from "react-icons/md";
import { connect } from "react-redux";
import { Link } from "react-router-dom";
import { createStructuredSelector } from "reselect";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { CHECK_BILL_INVOICE_NUMBER } from "../../graphql/bills.queries";
import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from "../../utils/day";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AlertComponent from "../alert/alert.component";
import BillFormLinesExtended from "../bill-form-lines-extended/bill-form-lines-extended.component";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from "../job-search-select/job-search-select.component";
@@ -21,8 +24,6 @@ import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from "./bill-form.totals.utility";
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -49,6 +50,8 @@ export function BillFormComponent({
const { t } = useTranslation();
const client = useApolloClient();
const [discount, setDiscount] = useState(0);
const notification = useNotification();
const jobIdFormWatch = Form.useWatch("jobid", form);
const {
treatments: { Extended_Bill_Posting, ClosingPeriod }
@@ -124,6 +127,23 @@ export function BillFormComponent({
bodyshop.inhousevendorid
]);
useEffect(() => {
console.log("*** Form Watch - jobid changed:", jobIdFormWatch);
if (jobIdFormWatch !== null) {
if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
loadLines({ variables: { id: form.getFieldValue("jobid") } });
if (form.getFieldValue("vendorid") !== null && form.getFieldValue("vendorid") !== undefined) {
loadOutstandingReturns({
variables: {
jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue("vendorid")
}
});
}
}
}
}, [jobIdFormWatch, form]);
return (
<div>
<FormFieldsChanged form={form} />
@@ -375,7 +395,15 @@ export function BillFormComponent({
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
totals = CalculateBillTotal(values);
try {
totals = CalculateBillTotal(values);
} catch (error) {
notification.error({
title: "Error calculating totals",
message: error.message || "An error occurred while calculating bill totals.",
key: "bill_totals_calculation_error"
});
}
}
if (totals) {

View File

@@ -154,10 +154,10 @@ export function BillEnterModalLinesComponent({
label: t("billlines.fields.confidence")
}),
formInput: (record) => {
const confidenceValue = getFieldValue(["billlines", record.name, "confidence"]);
const rowValue = getFieldValue(["billlines", record.name]);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<ConfidenceDisplay value={confidenceValue} />
<ConfidenceDisplay rowValue={rowValue} />
</div>
);
}
@@ -276,7 +276,20 @@ export function BillEnterModalLinesComponent({
key: `${field.name}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [{ required: true }]
rules: [
{ required: true },
{
validator: (_, value) => {
if (Math.abs(parseFloat(value)) < 0.01) {
return Promise.reject();
} else {
return Promise.resolve();
}
},
warningOnly: true
}
],
hasFeedback: true
}),
formInput: (record, index) => (
<CurrencyInput

View File

@@ -1,4 +1,4 @@
import { Progress, Tag, Tooltip } from "antd";
import { Progress, Space, Tag, Tooltip } from "antd";
import { useTranslation } from "react-i18next";
const parseConfidence = (confidenceStr) => {
if (!confidenceStr || typeof confidenceStr !== "string") return null;
@@ -20,10 +20,11 @@ const getConfidenceColor = (value) => {
return "red";
};
const ConfidenceDisplay = ({ value }) => {
const ConfidenceDisplay = ({ rowValue: { confidence, actual_price, actual_cost } }) => {
const { t } = useTranslation();
const parsed = parseConfidence(value);
const parsed = parseConfidence(confidence);
const parsed_actual_price = parseFloat(actual_price);
const parsed_actual_cost = parseFloat(actual_cost);
if (!parsed) {
return <span style={{ color: "#999", fontSize: "0.85em" }}>N/A</span>;
}
@@ -71,9 +72,16 @@ const ConfidenceDisplay = ({ value }) => {
</div>
}
>
<Tag color={color} style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{total.toFixed(0)}%
</Tag>
<Space size="small">
{!parsed_actual_cost || !parsed_actual_price || parsed_actual_cost === 0 || parsed_actual_price === 0 ? (
<Tag color="red" style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{t("billlines.confidence.missing_data", { defaultValue: "Missing Data" })}
</Tag>
) : null}
<Tag color={color} style={{ margin: 0, cursor: "help", userSelect: "none" }}>
{total.toFixed(0)}%
</Tag>
</Space>
</Tooltip>
);
};

View File

@@ -15,17 +15,14 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
if (value !== option) {
setOption(value);
}
}, [value]);
}, [value, option]);
useEffect(() => {
if (value !== option && onChange) {
if (value && !option) {
onChange(value);
} else {
onChange(option);
}
const handleChange = (newValue) => {
setOption(newValue);
if (onChange) {
onChange(newValue);
}
}, [value, option, onChange]);
};
const favorites =
preferredMake && options
@@ -69,7 +66,7 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
);
}}
popupMatchSelectWidth={false}
onChange={setOption}
onChange={handleChange}
optionFilterProp="name"
onSelect={onSelect}
disabled={disabled || false}