diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index cfb312ab1..2449aeb52 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -2696,6 +2696,27 @@ + + oem_partno + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + quantity false @@ -3684,6 +3705,48 @@ + + feedback_placeholder + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + feedback_prompt + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + generic_failure false @@ -3831,6 +3894,27 @@ + + submit_feedback + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + @@ -8641,6 +8725,27 @@ + + manual-line + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + partsqueue false @@ -17816,6 +17921,468 @@ labels + + banner_message + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + banner_status_connected + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + banner_status_disconnected + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + clear_logs + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + collapse_all + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + color_json + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + copied + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + copy + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + copy_request + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + copy_response + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + details + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + expand_all + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + hide_details + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + log_level + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + plain_json + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + provider_cdk + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + provider_dms + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + provider_fortellis + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + provider_pbs + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + provider_reynolds + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + reconnect + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + reconnected_export_service + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + refreshallocations false @@ -17837,6 +18404,153 @@ + + request_xml + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + response_xml + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + rr_validation_message + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + rr_validation_notice_description + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + rr_validation_notice_title + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + transport_ws + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + transport_wss + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + @@ -20590,6 +21304,27 @@ + + done + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + download false @@ -23009,6 +23744,27 @@ + + validationerror + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + view false @@ -23423,6 +24179,27 @@ validation + + array + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + dateRangeExceeded false @@ -57452,6 +58229,27 @@ + + work_in_progress_labour_summary + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + work_in_progress_payables false @@ -57473,6 +58271,27 @@ + + work_in_progress_payables_summary + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + diff --git a/client/src/components/bill-ai-feedback/bill-ai-feedback.component.jsx b/client/src/components/bill-ai-feedback/bill-ai-feedback.component.jsx new file mode 100644 index 000000000..0693937b1 --- /dev/null +++ b/client/src/components/bill-ai-feedback/bill-ai-feedback.component.jsx @@ -0,0 +1,104 @@ +import { DislikeOutlined, LikeOutlined } from "@ant-design/icons"; +import { Button, Form, Input, Radio, Space } from "antd"; +import axios from "axios"; +import { useState } from "react"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; +import { useTranslation } from "react-i18next"; + +function BillAiFeedback({ billForm, rawAIData }) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const notification = useNotification(); + + //Need to sanitize becuase we pass as form data to include the attachment. + const sanitizeBillFormValues = (value) => { + const seen = new WeakSet(); + return JSON.stringify( + value, + (key, v) => { + if (key === "originFileObj") return undefined; + if (key === "thumbUrl") return undefined; + if (key === "preview") return undefined; + if (typeof v === "function") return undefined; + if (v && typeof v === "object") { + if (seen.has(v)) return "[Circular]"; + seen.add(v); + } + return v; + }, + 0 + ); + }; + + const getAttachmentFromBillFormUpload = () => { + const uploads = billForm?.getFieldValue?.("upload") || []; + const files = uploads.map((u) => u?.originFileObj).filter(Boolean); + + return ( + files.find((f) => f?.type === "application/pdf") || + files.find((f) => isString(f?.name) && f.name.toLowerCase().endsWith(".pdf")) || + files[0] || + null + ); + }; + + const submitFeedback = async ({ rating, comments }) => { + setSubmitting(true); + try { + const billFormValues = billForm.getFieldsValue(true); + + const formData = new FormData(); + formData.append("rating", rating); + formData.append("comments", comments || ""); + formData.append("billFormValues", sanitizeBillFormValues(billFormValues)); + formData.append("rawAIData", sanitizeBillFormValues(rawAIData)); + + const attachmentFile = getAttachmentFromBillFormUpload(); + if (attachmentFile) { + formData.append("billPdf", attachmentFile, attachmentFile.name || "bill.pdf"); + } + + await axios.post("/ai/bill-feedback", formData); + + notification.success({ + title: "Thanks — feedback submitted" + }); + form.resetFields(); + } catch (error) { + notification.error({ + title: "Failed to submit feedback", + description: error?.response?.data?.message || error?.message + }); + } finally { + setSubmitting(false); + } + }; + + const isString = (v) => typeof v === "string"; + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ); +} +export default BillAiFeedback; diff --git a/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx b/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx index 453895b59..f4f9362d6 100644 --- a/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx +++ b/client/src/components/bill-enter-ai-scan/bill-enter-ai-scan.component.jsx @@ -23,7 +23,8 @@ function BillEnterAiScan({ fileInputRef, scanLoading, setScanLoading, - setIsAiScan + setIsAiScan, + setRawAIData }) { const notification = useNotification(); const { t } = useTranslation(); @@ -57,6 +58,7 @@ function BillEnterAiScan({ } setScanLoading(false); + setRawAIData(data.data); // Update form with the extracted data if (data?.data?.billForm) { form.setFieldsValue(data.data.billForm); @@ -147,6 +149,7 @@ function BillEnterAiScan({ setScanLoading(false); form.setFieldsValue(data.data.billForm); + setRawAIData(data.data); await form.validateFields(["billlines"], { recursive: true }); notification.success({ diff --git a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx index a73b19663..734b401a6 100644 --- a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx +++ b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx @@ -28,6 +28,7 @@ import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility"; import { handleUpload } from "../documents-upload/documents-upload.utility"; +import BillAiFeedback from "../bill-ai-feedback/bill-ai-feedback.component.jsx"; const mapStateToProps = createStructuredSelector({ billEnterModal: selectBillEnterModal, @@ -53,6 +54,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, const [loading, setLoading] = useState(false); const [scanLoading, setScanLoading] = useState(false); const [isAiScan, setIsAiScan] = useState(false); + const [rawAIData, setRawAIData] = useState(null); const client = useApolloClient(); const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false); const notification = useNotification(); @@ -387,6 +389,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, billlines: [] }); setIsAiScan(false); + setRawAIData(null); // form.resetFields(); } else { toggleModalVisible(); @@ -404,6 +407,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, } setScanLoading(false); setIsAiScan(false); + setRawAIData(null); toggleModalVisible(); } }; @@ -429,6 +433,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, } setScanLoading(false); setIsAiScan(false); + setRawAIData(null); } }, [billEnterModal.open, form, formValues]); @@ -456,6 +461,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, scanLoading={scanLoading} setScanLoading={setScanLoading} setIsAiScan={setIsAiScan} + setRawAIData={setRawAIData} /> )} @@ -471,7 +477,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, setLoading(false); }} footer={ - + + {isAiScan && } setGenerateLabel(e.target.checked)}> {t("bills.labels.generatepartslabel")} diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index e216b9cd5..a24453ae0 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -231,13 +231,16 @@ "overall": "Overall" }, "disclaimer_title": "AI Scan Beta Disclaimer", + "feedback_placeholder": "Tell us what worked, what didn't, and what could be better.", + "feedback_prompt": "Was this AI scan helpful?", "generic_failure": "Failed to process invoice.", "multipage": "The is a multi-page document. Processing will take a few moments.", "processing": "Analyzing Bill", "scan": "AI Bill Scanner", "scancomplete": "AI Scan Complete", "scanfailed": "AI Scan Failed", - "scanstarted": "AI Scan Started" + "scanstarted": "AI Scan Started", + "submit_feedback": "Submit Feedback" }, "bill_lines": "Bill Lines", "bill_total": "Bill Total Amount", @@ -1075,36 +1078,36 @@ "earlyrorequired.message": "This job requires an early Repair Order to be created before posting to Reynolds. Please use the admin panel to create the early RO first." }, "labels": { - "refreshallocations": "Refresh to see DMS Allocations.", - "provider_reynolds": "Reynolds", - "provider_fortellis": "Fortellis", - "provider_cdk": "CDK", - "provider_pbs": "PBS", - "provider_dms": "DMS", - "transport_wss": "(WSS)", - "transport_ws": "(WS)", + "banner_message": "Posting to {{provider}} | {{transport}} | {{status}}", "banner_status_connected": "Connected", "banner_status_disconnected": "Disconnected", - "banner_message": "Posting to {{provider}} | {{transport}} | {{status}}", - "reconnected_export_service": "Reconnected to {{provider}} Export Service", - "rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.", - "rr_validation_notice_title": "Reynolds RO created", - "rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.", - "color_json": "Color JSON", - "plain_json": "Plain JSON", - "collapse_all": "Collapse All", - "expand_all": "Expand All", - "log_level": "Log Level", "clear_logs": "Clear Logs", - "reconnect": "Reconnect", - "details": "Details", - "hide_details": "Hide details", - "copy": "Copy", + "collapse_all": "Collapse All", + "color_json": "Color JSON", "copied": "Copied", + "copy": "Copy", "copy_request": "Copy Request", "copy_response": "Copy Response", + "details": "Details", + "expand_all": "Expand All", + "hide_details": "Hide details", + "log_level": "Log Level", + "plain_json": "Plain JSON", + "provider_cdk": "CDK", + "provider_dms": "DMS", + "provider_fortellis": "Fortellis", + "provider_pbs": "PBS", + "provider_reynolds": "Reynolds", + "reconnect": "Reconnect", + "reconnected_export_service": "Reconnected to {{provider}} Export Service", + "refreshallocations": "Refresh to see DMS Allocations.", "request_xml": "Request XML", - "response_xml": "Response XML" + "response_xml": "Response XML", + "rr_validation_message": "Repair Order created in Reynolds. Complete validation in Reynolds, then click Finished/Close to finalize.", + "rr_validation_notice_description": "Complete validation in Reynolds, then click Finished/Close to finalize and mark this export complete.", + "rr_validation_notice_title": "Reynolds RO created", + "transport_ws": "(WS)", + "transport_wss": "(WSS)" } }, "documents": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 50b336d37..f16ca40e5 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -231,13 +231,16 @@ "overall": "" }, "disclaimer_title": "", + "feedback_placeholder": "", + "feedback_prompt": "", "generic_failure": "", "multipage": "", "processing": "", "scan": "", "scancomplete": "", "scanfailed": "", - "scanstarted": "" + "scanstarted": "", + "submit_feedback": "" }, "bill_lines": "", "bill_total": "", @@ -1075,36 +1078,36 @@ "earlyrorequired.message": "" }, "labels": { - "refreshallocations": "", - "provider_reynolds": "", - "provider_fortellis": "", - "provider_cdk": "", - "provider_pbs": "", - "provider_dms": "", - "transport_wss": "", - "transport_ws": "", + "banner_message": "", "banner_status_connected": "", "banner_status_disconnected": "", - "banner_message": "", - "reconnected_export_service": "", - "rr_validation_message": "", - "rr_validation_notice_title": "", - "rr_validation_notice_description": "", - "color_json": "", - "plain_json": "", - "collapse_all": "", - "expand_all": "", - "log_level": "", "clear_logs": "", - "reconnect": "", - "details": "", - "hide_details": "", - "copy": "", + "collapse_all": "", + "color_json": "", "copied": "", + "copy": "", "copy_request": "", "copy_response": "", + "details": "", + "expand_all": "", + "hide_details": "", + "log_level": "", + "plain_json": "", + "provider_cdk": "", + "provider_dms": "", + "provider_fortellis": "", + "provider_pbs": "", + "provider_reynolds": "", + "reconnect": "", + "reconnected_export_service": "", + "refreshallocations": "", "request_xml": "", - "response_xml": "" + "response_xml": "", + "rr_validation_message": "", + "rr_validation_notice_description": "", + "rr_validation_notice_title": "", + "transport_ws": "", + "transport_wss": "" } }, "documents": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 727d689f8..8004d5e6b 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -231,13 +231,16 @@ "overall": "" }, "disclaimer_title": "", + "feedback_placeholder": "", + "feedback_prompt": "", "generic_failure": "", "multipage": "", "processing": "", "scan": "", "scancomplete": "", "scanfailed": "", - "scanstarted": "" + "scanstarted": "", + "submit_feedback": "" }, "bill_lines": "", "bill_total": "", @@ -1075,36 +1078,36 @@ "earlyrorequired.message": "" }, "labels": { - "refreshallocations": "", - "provider_reynolds": "", - "provider_fortellis": "", - "provider_cdk": "", - "provider_pbs": "", - "provider_dms": "", - "transport_wss": "", - "transport_ws": "", + "banner_message": "", "banner_status_connected": "", "banner_status_disconnected": "", - "banner_message": "", - "reconnected_export_service": "", - "rr_validation_message": "", - "rr_validation_notice_title": "", - "rr_validation_notice_description": "", - "color_json": "", - "plain_json": "", - "collapse_all": "", - "expand_all": "", - "log_level": "", "clear_logs": "", - "reconnect": "", - "details": "", - "hide_details": "", - "copy": "", + "collapse_all": "", + "color_json": "", "copied": "", + "copy": "", "copy_request": "", "copy_response": "", + "details": "", + "expand_all": "", + "hide_details": "", + "log_level": "", + "plain_json": "", + "provider_cdk": "", + "provider_dms": "", + "provider_fortellis": "", + "provider_pbs": "", + "provider_reynolds": "", + "reconnect": "", + "reconnected_export_service": "", + "refreshallocations": "", "request_xml": "", - "response_xml": "" + "response_xml": "", + "rr_validation_message": "", + "rr_validation_notice_description": "", + "rr_validation_notice_title": "", + "transport_ws": "", + "transport_wss": "" } }, "documents": { diff --git a/localstack/init/10-bootstrap.sh b/localstack/init/10-bootstrap.sh old mode 100644 new mode 100755 diff --git a/server/ai/bill-ai-feedback.js b/server/ai/bill-ai-feedback.js new file mode 100644 index 000000000..e136c60f4 --- /dev/null +++ b/server/ai/bill-ai-feedback.js @@ -0,0 +1,72 @@ +const { isString } = require("lodash"); +const { sendServerEmail } = require("../email/sendemail"); +const logger = require("../utils/logger"); +const { raw } = require("express"); + +const SUPPORT_EMAIL = "patrick@imexsystems.ca"; + +const safeJsonParse = (maybeJson) => { + if (!isString(maybeJson)) return null; + try { + return JSON.parse(maybeJson); + } catch { + return null; + } +}; + + +const handleBillAiFeedback = async (req, res) => { + try { + const rating = req.body?.rating; + const comments = isString(req.body?.comments) ? req.body?.comments?.trim() : ""; + + const billFormValues = safeJsonParse(req.body?.billFormValues); + const rawAIData = safeJsonParse(req.body?.rawAIData); + + const jobid = billFormValues?.jobid || billFormValues?.jobId || "unknown"; + const subject = `Bill AI Feedback (${rating === "up" ? "+" : "-"}) jobid=${jobid}`; + + const text = [ + `User: ${req?.user?.email || "unknown"}`, + `Rating: ${rating}`, + comments ? `Comments: ${comments}` : "Comments: (none)", + "", + "Form Values (User):", + JSON.stringify(billFormValues, null, 4), + "", + "Raw AI Data:", + JSON.stringify(rawAIData, null, 4) + ] + .filter(Boolean) + .join("\n"); + + const attachments = []; + if (req.file?.buffer) { + attachments.push({ + filename: req.file.originalname || `bill-${jobid}.pdf`, + content: req.file.buffer, + contentType: req.file.mimetype || "application/pdf" + }); + } + + await sendServerEmail({ + to: [SUPPORT_EMAIL], + subject, + type: "text", + text, + attachments + }); + + return res.json({ success: true }); + } catch (error) { + logger.log("bill-ai-feedback-error", "ERROR", req?.user?.email, null, { + message: error?.message, + stack: error?.stack + }); + return res.status(500).json({ message: "Failed to submit feedback" }); + } +}; + +module.exports = { + handleBillAiFeedback +}; diff --git a/server/ai/bill-ocr/bill-ocr.js b/server/ai/bill-ocr/bill-ocr.js index 26e77033a..6c1101207 100644 --- a/server/ai/bill-ocr/bill-ocr.js +++ b/server/ai/bill-ocr/bill-ocr.js @@ -212,7 +212,8 @@ async function processSinglePageDocument(pdfBuffer) { return { ...processedData, - originalTextractResponse: result + //Removed as this is a large object that provides minimal value to send to client. + // originalTextractResponse: result }; } @@ -392,7 +393,8 @@ async function handleTextractNotification(message) { status: 'COMPLETED', data: { ...processedData, - originalTextractResponse: originalResponse + //Removed as this is a large object that provides minimal value to send to client. + // originalTextractResponse: originalResponse }, completedAt: new Date().toISOString() } diff --git a/server/email/sendemail.js b/server/email/sendemail.js index e39a2093c..bcef44c0a 100644 --- a/server/email/sendemail.js +++ b/server/email/sendemail.js @@ -44,7 +44,7 @@ const logEmail = async (req, email) => { } }; -const sendServerEmail = async ({ subject, text, to = [] }) => { +const sendServerEmail = async ({ subject, text, to = [], attachments }) => { if (process.env.NODE_ENV === undefined) return; try { @@ -57,6 +57,7 @@ const sendServerEmail = async ({ subject, text, to = [] }) => { to: ["support@imexsystems.ca", ...to], subject: subject, text: text, + attachments: attachments, ses: { // optional extra arguments for SendRawEmail Tags: [ diff --git a/server/routes/aiRoutes.js b/server/routes/aiRoutes.js index f06125718..b92b1b69d 100644 --- a/server/routes/aiRoutes.js +++ b/server/routes/aiRoutes.js @@ -4,9 +4,14 @@ const multer = require("multer"); const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware"); const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware"); const { handleBillOcr, handleBillOcrStatus } = require("../ai/bill-ocr/bill-ocr"); +const { handleBillAiFeedback } = require("../ai/bill-ai-feedback"); -// Configure multer for form data parsing -const upload = multer(); +// Configure multer for form data parsing (memory storage) +const upload = multer({ + limits: { + fileSize: 5 * 1024 * 1024 // 5MB + } +}); router.use(validateFirebaseIdTokenMiddleware); router.use(withUserGraphQLClientMiddleware); @@ -14,4 +19,6 @@ router.use(withUserGraphQLClientMiddleware); router.post("/bill-ocr", upload.single('billScan'), handleBillOcr); router.get("/bill-ocr/status/:textractJobId", handleBillOcrStatus); +router.post("/bill-feedback", upload.single("billPdf"), handleBillAiFeedback); + module.exports = router;