@@ -1,4 +1,4 @@
|
||||
<babeledit_project version="1.2" be_version="2.7.1">
|
||||
<babeledit_project be_version="2.7.1" version="1.2">
|
||||
<!--
|
||||
|
||||
BabelEdit project file
|
||||
@@ -2549,6 +2549,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>confidence</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>cost_center</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -2994,6 +3015,48 @@
|
||||
<folder_node>
|
||||
<name>errors</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>calculating_totals</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>calculating_totals_generic</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>creating</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -3466,6 +3529,310 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<folder_node>
|
||||
<name>ai</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>accept_and_continue</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<folder_node>
|
||||
<name>confidence</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>breakdown</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>match</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>missing_data</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>ocr</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>overall</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
<name>disclaimer_title</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>generic_failure</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>multipage</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>processing</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>scan</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>scancomplete</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>scanfailed</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>scanstarted</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
<name>bill_lines</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -17402,6 +17769,48 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>earlyrorequired</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>earlyrorequired.message</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<folder_node>
|
||||
@@ -20223,6 +20632,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>gotoadmin</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>login</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -21020,6 +21450,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>apply</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>areyousure</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -21062,6 +21513,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>beta</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>cancel</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -26660,6 +27132,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>convertwithoutearlyro</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>createiou</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -26747,6 +27240,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>createearlyro</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>createnewcustomer</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -26878,6 +27392,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>update_ro</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>usegeneric</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -29958,6 +30493,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>customer</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>dms_make</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -30362,6 +30918,90 @@
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
<name>rr_opcode</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_opcode_base</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_opcode_prefix</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>rr_opcode_suffix</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>sale</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -35966,6 +36606,74 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<folder_node>
|
||||
<name>earlyro</name>
|
||||
<children>
|
||||
<concept_node>
|
||||
<name>created</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>fields</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>willupdate</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
</children>
|
||||
</folder_node>
|
||||
<concept_node>
|
||||
<name>invoicedatefuture</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
@@ -39059,6 +39767,27 @@
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>early_ro_created</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
<description></description>
|
||||
<comment></comment>
|
||||
<default_text></default_text>
|
||||
<translations>
|
||||
<translation>
|
||||
<language>en-US</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>es-MX</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
<translation>
|
||||
<language>fr-CA</language>
|
||||
<approved>false</approved>
|
||||
</translation>
|
||||
</translations>
|
||||
</concept_node>
|
||||
<concept_node>
|
||||
<name>exported</name>
|
||||
<definition_loaded>false</definition_loaded>
|
||||
|
||||
843
client/package-lock.json
generated
843
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,9 +8,9 @@
|
||||
"private": true,
|
||||
"proxy": "http://localhost:4000",
|
||||
"dependencies": {
|
||||
"@amplitude/analytics-browser": "^2.34.0",
|
||||
"@amplitude/analytics-browser": "^2.35.0",
|
||||
"@ant-design/pro-layout": "^7.22.6",
|
||||
"@apollo/client": "^4.1.3",
|
||||
"@apollo/client": "^4.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -18,35 +18,35 @@
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@firebase/analytics": "^0.10.19",
|
||||
"@firebase/app": "^0.14.7",
|
||||
"@firebase/app": "^0.14.8",
|
||||
"@firebase/auth": "^1.12.0",
|
||||
"@firebase/firestore": "^4.10.0",
|
||||
"@firebase/firestore": "^4.11.0",
|
||||
"@firebase/messaging": "^0.12.22",
|
||||
"@jsreport/browser-client": "^3.1.0",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@sentry/cli": "^3.1.0",
|
||||
"@sentry/react": "^10.38.0",
|
||||
"@sentry/vite-plugin": "^4.8.0",
|
||||
"@sentry/cli": "^3.2.0",
|
||||
"@sentry/react": "^10.39.0",
|
||||
"@sentry/vite-plugin": "^4.9.1",
|
||||
"@splitsoftware/splitio-react": "^2.6.1",
|
||||
"@tanem/react-nprogress": "^5.0.58",
|
||||
"antd": "^6.2.2",
|
||||
"@tanem/react-nprogress": "^5.0.63",
|
||||
"antd": "^6.3.0",
|
||||
"apollo-link-logger": "^3.0.0",
|
||||
"autosize": "^6.0.1",
|
||||
"axios": "^1.13.4",
|
||||
"axios": "^1.13.5",
|
||||
"classnames": "^2.5.1",
|
||||
"css-box-model": "^1.2.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"dayjs-business-days2": "^1.3.2",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"env-cmd": "^11.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-ws": "^6.0.7",
|
||||
"i18next": "^25.8.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next": "^25.8.11",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"libphonenumber-js": "^1.12.36",
|
||||
"libphonenumber-js": "^1.12.37",
|
||||
"lightningcss": "^1.31.1",
|
||||
"logrocket": "^12.0.0",
|
||||
"markerjs2": "^2.32.7",
|
||||
@@ -54,7 +54,7 @@
|
||||
"normalize-url": "^8.1.1",
|
||||
"object-hash": "^3.0.0",
|
||||
"phone": "^3.1.70",
|
||||
"posthog-js": "^1.336.4",
|
||||
"posthog-js": "^1.351.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"query-string": "^9.3.1",
|
||||
"raf-schd": "^4.0.3",
|
||||
@@ -87,7 +87,7 @@
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.97.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"styled-components": "^6.3.8",
|
||||
"styled-components": "^6.3.10",
|
||||
"vite-plugin-ejs": "^1.7.0",
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
@@ -144,11 +144,11 @@
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"browserslist": "^4.28.1",
|
||||
"browserslist-to-esbuild": "^2.1.1",
|
||||
@@ -156,16 +156,16 @@
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"globals": "^17.2.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"globals": "^17.3.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"memfs": "^4.56.10",
|
||||
"os-browserify": "^0.3.0",
|
||||
"playwright": "^1.58.0",
|
||||
"playwright": "^1.58.2",
|
||||
"react-error-overlay": "^6.1.0",
|
||||
"redux-logger": "^3.0.6",
|
||||
"source-map-explorer": "^2.5.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-babel": "^1.4.1",
|
||||
"vite-plugin-babel": "^1.5.1",
|
||||
"vite-plugin-eslint": "^1.8.1",
|
||||
"vite-plugin-node-polyfills": "^0.25.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
|
||||
@@ -29,19 +29,18 @@ export function AllocationsAssignmentComponent({
|
||||
<Select
|
||||
id="employeeSelector"
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
placeholder="Select a person"
|
||||
onChange={onChange}
|
||||
>
|
||||
{bodyshop.employees.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.employees.map((emp) => ({
|
||||
value: emp.id,
|
||||
key: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`
|
||||
}))}
|
||||
/>
|
||||
<InputNumber
|
||||
defaultValue={assignment.hours}
|
||||
placeholder={t("joblines.fields.mod_lb_hrs")}
|
||||
|
||||
@@ -31,19 +31,17 @@ export default connect(
|
||||
<div>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
placeholder="Select a person"
|
||||
onChange={onChange}
|
||||
>
|
||||
{bodyshop.employees.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.employees.map((emp) => ({
|
||||
value: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`
|
||||
}))}
|
||||
/>
|
||||
|
||||
<Button type="primary" disabled={!assignment.employeeid} onClick={handleAssignment}>
|
||||
Assign
|
||||
|
||||
@@ -28,6 +28,20 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const initialValues =
|
||||
data && data.bills_by_pk
|
||||
? {
|
||||
...data.bills_by_pk,
|
||||
billlines: (data.bills_by_pk.billlines || []).map((bl) => {
|
||||
const oem = bl.oem_partno || (bl.jobline && bl.jobline.oem_partno) || "";
|
||||
const alt = bl.alt_partno || (bl.jobline && bl.jobline.alt_partno) || "";
|
||||
return {
|
||||
...bl,
|
||||
oem_partno: `${oem || ""} ${alt ? `(${alt})` : ""}`.trim()
|
||||
};
|
||||
})
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const handleFinish = ({ billlines }) => {
|
||||
const selectedLines = billlines.filter((l) => l.selected).map((l) => l.id);
|
||||
@@ -74,8 +88,9 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
destroyOnHidden
|
||||
title={t("bills.actions.return")}
|
||||
onOk={() => form.submit()}
|
||||
width={700}
|
||||
>
|
||||
<Form initialValues={data?.bills_by_pk} onFinish={handleFinish} form={form}>
|
||||
<Form initialValues={initialValues} onFinish={handleFinish} form={form}>
|
||||
<Form.List name={["billlines"]}>
|
||||
{(fields) => {
|
||||
return (
|
||||
@@ -95,9 +110,10 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
/>
|
||||
</td>
|
||||
<td>{t("billlines.fields.line_desc")}</td>
|
||||
<td>{t("billlines.fields.quantity")}</td>
|
||||
<td>{t("billlines.fields.actual_price")}</td>
|
||||
<td>{t("billlines.fields.actual_cost")}</td>
|
||||
<td>{t("billlines.fields.oem_partno")}</td>
|
||||
<td style={{ textAlign: "right" }}>{t("billlines.fields.quantity")}</td>
|
||||
<td style={{ textAlign: "right" }}>{t("billlines.fields.actual_price")}</td>
|
||||
<td style={{ textAlign: "right" }}>{t("billlines.fields.actual_cost")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -127,6 +143,15 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.oem_partno")}
|
||||
key={`${index}jobline.oem_partno`}
|
||||
name={[field.name, "oem_partno"]}
|
||||
>
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.quantity")}
|
||||
key={`${index}quantity`}
|
||||
@@ -135,7 +160,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
<ReadOnlyFormItemComponent />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.actual_price")}
|
||||
key={`${index}actual_price`}
|
||||
@@ -144,7 +169,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
|
||||
<ReadOnlyFormItemComponent type="currency" />
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<Form.Item
|
||||
// label={t("joblines.fields.actual_cost")}
|
||||
key={`${index}actual_cost`}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
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 { useTranslation } from "react-i18next";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
billEnterModal: selectBillEnterModal,
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
function BillEnterAiScan({
|
||||
billEnterModal,
|
||||
bodyshop,
|
||||
pollingIntervalRef,
|
||||
setPollingIntervalRef,
|
||||
form,
|
||||
fileInputRef,
|
||||
scanLoading,
|
||||
setScanLoading,
|
||||
setIsAiScan
|
||||
}) {
|
||||
const notification = useNotification();
|
||||
const { t } = useTranslation();
|
||||
const [showBetaModal, setShowBetaModal] = useState(false);
|
||||
const BETA_ACCEPTANCE_KEY = "ai_scan_beta_acceptance";
|
||||
const handleBetaAcceptance = () => {
|
||||
localStorage.setItem(BETA_ACCEPTANCE_KEY, "true");
|
||||
setShowBetaModal(false);
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const checkBetaAcceptance = () => {
|
||||
const hasAccepted = localStorage.getItem(BETA_ACCEPTANCE_KEY);
|
||||
if (hasAccepted) {
|
||||
fileInputRef.current?.click();
|
||||
} else {
|
||||
setShowBetaModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Polling function for multipage PDF status
|
||||
const pollJobStatus = async (textractJobId) => {
|
||||
try {
|
||||
const { data } = await axios.get(`/ai/bill-ocr/status/${textractJobId}`);
|
||||
|
||||
if (data.status === "COMPLETED") {
|
||||
// Stop polling
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
setPollingIntervalRef(null);
|
||||
}
|
||||
setScanLoading(false);
|
||||
|
||||
// Update form with the extracted data
|
||||
if (data?.data?.billForm) {
|
||||
form.setFieldsValue(data.data.billForm);
|
||||
await form.validateFields(["billlines"], { recursive: true });
|
||||
notification.success({
|
||||
title: t("bills.labels.ai.scancomplete")
|
||||
});
|
||||
}
|
||||
} else if (data.status === "FAILED") {
|
||||
// Stop polling on failure
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
setPollingIntervalRef(null);
|
||||
}
|
||||
setScanLoading(false);
|
||||
|
||||
notification.error({
|
||||
title: t("bills.labels.ai.scanfailed"),
|
||||
description: data.error || ""
|
||||
});
|
||||
}
|
||||
// If status is IN_PROGRESS, continue polling
|
||||
} catch (error) {
|
||||
// Stop polling on error
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
setPollingIntervalRef(null);
|
||||
}
|
||||
setScanLoading(false);
|
||||
|
||||
notification.error({
|
||||
title: t("bills.labels.ai.scanfailed"),
|
||||
description: error.response?.data?.message || error.message || "Failed to check scan status"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: "none" }}
|
||||
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("bodyshopid", bodyshop.id);
|
||||
formdata.append("partsorderid", billEnterModal.context.parts_order?.id);
|
||||
|
||||
try {
|
||||
const { data, status } = await axios.post("/ai/bill-ocr", formdata);
|
||||
|
||||
// Add the scanned file to the upload field
|
||||
const currentUploads = form.getFieldValue("upload") || [];
|
||||
form.setFieldValue("upload", [
|
||||
...currentUploads,
|
||||
{
|
||||
uid: `ai-scan-${Date.now()}`,
|
||||
name: file.name,
|
||||
originFileObj: file,
|
||||
status: "done"
|
||||
}
|
||||
]);
|
||||
if (status === 202) {
|
||||
// Multipage PDF - start polling
|
||||
notification.info({
|
||||
title: t("bills.labels.ai.scanstarted"),
|
||||
description: t("bills.labels.ai.multipage")
|
||||
});
|
||||
|
||||
//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: t("bills.labels.ai.scancomplete")
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setScanLoading(false);
|
||||
notification.error({
|
||||
title: t("bills.labels.ai.scanfailed"),
|
||||
description: error.response?.data?.message || error.message || t("bills.labels.ai.generic_failure")
|
||||
});
|
||||
}
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button onClick={checkBetaAcceptance} icon={<FaWandMagicSparkles />} loading={scanLoading} disabled={scanLoading}>
|
||||
{scanLoading ? t("bills.labels.ai.processing") : t("bills.labels.ai.scan")}
|
||||
<Tag color="red">{t("general.labels.beta")}</Tag>
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={t("bills.labels.ai.disclaimer_title")}
|
||||
open={showBetaModal}
|
||||
onOk={handleBetaAcceptance}
|
||||
onCancel={() => setShowBetaModal(false)}
|
||||
okText={t("bills.labels.ai.accept_and_continue")}
|
||||
cancelText={t("general.actions.cancel")}
|
||||
>
|
||||
{
|
||||
//This is explicitly not translated.
|
||||
}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default connect(mapStateToProps, null)(BillEnterAiScan);
|
||||
@@ -2,10 +2,11 @@ import { useApolloClient, useMutation } from "@apollo/client/react";
|
||||
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
|
||||
import { Button, Checkbox, Form, Modal, Space } from "antd";
|
||||
import _ from "lodash";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
|
||||
import { INSERT_NEW_BILL } from "../../graphql/bills.queries";
|
||||
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries";
|
||||
@@ -21,12 +22,12 @@ import { GenerateDocument } from "../../utils/RenderTemplate";
|
||||
import { TemplateList } from "../../utils/TemplateConstants";
|
||||
import confirmDialog from "../../utils/asyncConfirm";
|
||||
import useLocalStorage from "../../utils/useLocalStorage";
|
||||
import BillEnterAiScan from "../bill-enter-ai-scan/bill-enter-ai-scan.component.jsx";
|
||||
import BillFormContainer from "../bill-form/bill-form.container";
|
||||
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
|
||||
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility";
|
||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||
import { handleUpload as handleUploadToImageProxy } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility";
|
||||
import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
|
||||
import { handleUpload } from "../documents-upload/documents-upload.utility";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
billEnterModal: selectBillEnterModal,
|
||||
@@ -50,15 +51,20 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
|
||||
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [isAiScan, setIsAiScan] = useState(false);
|
||||
const client = useApolloClient();
|
||||
const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
|
||||
const notification = useNotification();
|
||||
const fileInputRef = useRef(null);
|
||||
const pollingIntervalRef = useRef(null);
|
||||
const formTopRef = useRef(null);
|
||||
|
||||
const {
|
||||
treatments: { Enhanced_Payroll, Imgproxy }
|
||||
treatments: { Enhanced_Payroll, Imgproxy, Bill_OCR_AI }
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll", "Imgproxy"],
|
||||
names: ["Enhanced_Payroll", "Imgproxy", "Bill_OCR_AI"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
@@ -113,6 +119,8 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
create_ppc,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
original_actual_price,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
confidence,
|
||||
...restI
|
||||
} = i;
|
||||
|
||||
@@ -378,6 +386,7 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
vendorid: values.vendorid,
|
||||
billlines: []
|
||||
});
|
||||
setIsAiScan(false);
|
||||
// form.resetFields();
|
||||
} else {
|
||||
toggleModalVisible();
|
||||
@@ -388,10 +397,22 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
const handleCancel = () => {
|
||||
const r = window.confirm(t("general.labels.cancel"));
|
||||
if (r === true) {
|
||||
// Clean up polling on cancel
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
setScanLoading(false);
|
||||
setIsAiScan(false);
|
||||
toggleModalVisible();
|
||||
}
|
||||
};
|
||||
|
||||
//Workaround needed to bypass react-compiler error about manipulating refs in child components. Refactor may be needed in the future to clean this up.
|
||||
const setPollingIntervalRef = (func) => {
|
||||
pollingIntervalRef.current = func;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (enterAgain) form.submit();
|
||||
}, [enterAgain, form]);
|
||||
@@ -401,12 +422,44 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
form.setFieldsValue(formValues);
|
||||
} else {
|
||||
form.resetFields();
|
||||
// Clean up polling on modal close
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
setScanLoading(false);
|
||||
setIsAiScan(false);
|
||||
}
|
||||
}, [billEnterModal.open, form, formValues]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("bills.labels.new")}
|
||||
title={
|
||||
<Space size="large">
|
||||
{t("bills.labels.new")}
|
||||
{Bill_OCR_AI.treatment === "on" && (
|
||||
<BillEnterAiScan
|
||||
fileInputRef={fileInputRef}
|
||||
form={form}
|
||||
pollingIntervalRef={pollingIntervalRef}
|
||||
setPollingIntervalRef={setPollingIntervalRef}
|
||||
scanLoading={scanLoading}
|
||||
setScanLoading={setScanLoading}
|
||||
setIsAiScan={setIsAiScan}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
width={"98%"}
|
||||
open={billEnterModal.open}
|
||||
okText={t("general.actions.save")}
|
||||
@@ -447,13 +500,25 @@ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop,
|
||||
autoComplete={"off"}
|
||||
layout="vertical"
|
||||
form={form}
|
||||
onFinishFailed={() => {
|
||||
onFinishFailed={(errorInfo) => {
|
||||
setEnterAgain(false);
|
||||
// Scroll to the top of the form to show validation errors
|
||||
if (errorInfo.errorFields && errorInfo.errorFields.length > 0) {
|
||||
setTimeout(() => {
|
||||
formTopRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RbacWrapper action="bills:enter">
|
||||
<BillFormContainer form={form} disableInvNumber={billEnterModal.context.disableInvNumber} />
|
||||
</RbacWrapper>
|
||||
<div ref={formTopRef}>
|
||||
<RbacWrapper action="bills:enter">
|
||||
<BillFormContainer
|
||||
form={form}
|
||||
isAiScan={isAiScan}
|
||||
disableInvNumber={billEnterModal.context.disableInvNumber}
|
||||
/>
|
||||
</RbacWrapper>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -99,20 +99,22 @@ export function BillFormItemsExtendedFormItem({
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item label={t("billlines.fields.cost_center")} name={["billlineskeys", record.id, "cost_center"]}>
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "3rem" }}
|
||||
disabled={disabled}
|
||||
options={
|
||||
bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("billlines.fields.location")} name={["billlineskeys", record.id, "location"]}>
|
||||
<Select disabled={disabled}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("billlines.fields.deductedfromlbr")}
|
||||
@@ -136,22 +138,10 @@ export function BillFormItemsExtendedFormItem({
|
||||
]}
|
||||
name={["billlineskeys", record.id, "lbr_adjustment", "mod_lbr_ty"]}
|
||||
>
|
||||
<Select allowClear>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={CiecaSelect(false, true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.labels.adjustmentrate")}
|
||||
|
||||
@@ -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
|
||||
@@ -43,11 +44,14 @@ export function BillFormComponent({
|
||||
loadOutstandingReturns,
|
||||
loadInventory,
|
||||
preferredMake,
|
||||
disableInHouse
|
||||
disableInHouse,
|
||||
isAiScan
|
||||
}) {
|
||||
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 }
|
||||
@@ -123,6 +127,23 @@ export function BillFormComponent({
|
||||
bodyshop.inhousevendorid
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// When the jobid is set by AI scan, we need to reload the lines. This prevents having to hoist the apollo query.
|
||||
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} />
|
||||
@@ -328,13 +349,12 @@ export function BillFormComponent({
|
||||
</Form.Item>
|
||||
{!billEdit && (
|
||||
<Form.Item label={t("bills.fields.allpartslocation")} name="location">
|
||||
<Select style={{ width: "10rem" }} disabled={disabled} allowClear>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
style={{ width: "10rem" }}
|
||||
disabled={disabled}
|
||||
allowClear
|
||||
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</LayoutFormRow>
|
||||
@@ -374,7 +394,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: t("bills.errors.calculating_totals"),
|
||||
message: error.message || t("bills.errors.calculating_totals_generic"),
|
||||
key: "bill_totals_calculation_error"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (totals) {
|
||||
@@ -452,6 +480,7 @@ export function BillFormComponent({
|
||||
responsibilityCenters={responsibilityCenters}
|
||||
disabled={disabled}
|
||||
billEdit={billEdit}
|
||||
isAiScan={isAiScan}
|
||||
/>
|
||||
)}
|
||||
<Divider titlePlacement="left" style={{ display: billEdit ? "none" : null }}>
|
||||
|
||||
@@ -15,7 +15,7 @@ const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop
|
||||
});
|
||||
|
||||
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse }) {
|
||||
export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableInvNumber, disableInHouse,isAiScan }) {
|
||||
const {
|
||||
treatments: { Simple_Inventory }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -50,6 +50,7 @@ export function BillFormContainer({ bodyshop, form, billEdit, disabled, disableI
|
||||
loadOutstandingReturns={loadOutstandingReturns}
|
||||
loadInventory={loadInventory}
|
||||
preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null}
|
||||
isAiScan={isAiScan}
|
||||
/>
|
||||
{!billEdit && <BillCmdReturnsTableComponent form={form} returnLoading={returnLoading} returnData={returnData} />}
|
||||
{Simple_Inventory.treatment === "on" && (
|
||||
|
||||
@@ -5,14 +5,15 @@ import { useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import { selectDarkMode } from "../../redux/application/application.selectors";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import CiecaSelect from "../../utils/Ciecaselect";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
import InstanceRenderManager from "../../utils/instanceRenderMgr";
|
||||
import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component";
|
||||
import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import { bodyshopHasDmsKey } from "../../utils/dmsUtils.js";
|
||||
import ConfidenceDisplay from "./bill-form.lines.confidence.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -29,7 +30,8 @@ export function BillEnterModalLinesComponent({
|
||||
discount,
|
||||
form,
|
||||
responsibilityCenters,
|
||||
billEdit
|
||||
billEdit,
|
||||
isAiScan
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
|
||||
@@ -139,6 +141,29 @@ export function BillEnterModalLinesComponent({
|
||||
|
||||
const columns = (remove) => {
|
||||
return [
|
||||
...(isAiScan
|
||||
? [
|
||||
{
|
||||
title: t("billlines.fields.confidence"),
|
||||
dataIndex: "confidence",
|
||||
editable: true,
|
||||
width: "5rem",
|
||||
formItemProps: (field) => ({
|
||||
key: `${field.index}confidence`,
|
||||
name: [field.name, "confidence"],
|
||||
label: t("billlines.fields.confidence")
|
||||
}),
|
||||
formInput: (record) => {
|
||||
const rowValue = getFieldValue(["billlines", record.name]);
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<ConfidenceDisplay rowValue={rowValue} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: t("billlines.fields.jobline"),
|
||||
dataIndex: "joblineid",
|
||||
@@ -212,6 +237,7 @@ export function BillEnterModalLinesComponent({
|
||||
}),
|
||||
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
|
||||
},
|
||||
|
||||
{
|
||||
title: t("billlines.fields.quantity"),
|
||||
dataIndex: "quantity",
|
||||
@@ -250,7 +276,16 @@ 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) => {
|
||||
return Math.abs(parseFloat(value)) < 0.01 ? Promise.reject() : Promise.resolve();
|
||||
},
|
||||
warningOnly: true
|
||||
}
|
||||
],
|
||||
hasFeedback: true
|
||||
}),
|
||||
formInput: (record, index) => (
|
||||
<CurrencyInput
|
||||
@@ -399,11 +434,17 @@ export function BillEnterModalLinesComponent({
|
||||
rules: [{ required: true }]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
|
||||
{bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
|
||||
</Select>
|
||||
<Select
|
||||
showSearch
|
||||
style={{ minWidth: "3rem" }}
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
options={
|
||||
bodyshopHasDmsKey(bodyshop)
|
||||
? CiecaSelect(true, false)
|
||||
: responsibilityCenters.costs.map((item) => ({ value: item.name, label: item.name }))
|
||||
}
|
||||
/>
|
||||
)
|
||||
},
|
||||
...(billEdit
|
||||
@@ -419,13 +460,11 @@ export function BillEnterModalLinesComponent({
|
||||
name: [field.name, "location"]
|
||||
}),
|
||||
formInput: () => (
|
||||
<Select disabled={disabled} tabIndex={0}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
tabIndex={0}
|
||||
options={bodyshop.md_parts_locations.map((loc) => ({ value: loc, label: loc }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]),
|
||||
@@ -466,22 +505,10 @@ export function BillEnterModalLinesComponent({
|
||||
rules={[{ required: true }]}
|
||||
name={[record.name, "lbr_adjustment", "mod_lbr_ty"]}
|
||||
>
|
||||
<Select allowClear>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={CiecaSelect(false, true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Progress, Space, Tag, Tooltip } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
const parseConfidence = (confidenceStr) => {
|
||||
if (!confidenceStr || typeof confidenceStr !== "string") return null;
|
||||
|
||||
const match = confidenceStr.match(/T([\d.]+)\s*-\s*O([\d.]+)\s*-\s*J([\d.]+)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
total: parseFloat(match[1]),
|
||||
ocr: parseFloat(match[2]),
|
||||
jobMatch: parseFloat(match[3])
|
||||
};
|
||||
};
|
||||
|
||||
const getConfidenceColor = (value) => {
|
||||
if (value >= 80) return "green";
|
||||
if (value >= 60) return "orange";
|
||||
if (value >= 40) return "gold";
|
||||
return "red";
|
||||
};
|
||||
|
||||
const ConfidenceDisplay = ({ rowValue: { confidence, actual_price, actual_cost } }) => {
|
||||
const { t } = useTranslation();
|
||||
const parsed = parseConfidence(confidence);
|
||||
const parsed_actual_price = parseFloat(actual_price);
|
||||
const parsed_actual_cost = parseFloat(actual_cost);
|
||||
if (!parsed) {
|
||||
return <span style={{ color: "#959595", fontSize: "0.85em" }}>N/A</span>;
|
||||
}
|
||||
|
||||
const { total, ocr, jobMatch } = parsed;
|
||||
const color = getConfidenceColor(total);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>{t("bills.labels.ai.confidence.breakdown")}</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>{t("bills.labels.ai.confidence.overall")}:</strong> {total.toFixed(1)}%
|
||||
<Progress
|
||||
percent={total}
|
||||
size="small"
|
||||
strokeColor={getConfidenceColor(total)}
|
||||
showInfo={false}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<strong>{t("bills.labels.ai.confidence.ocr")}:</strong> {ocr.toFixed(1)}%
|
||||
<Progress
|
||||
percent={ocr}
|
||||
size="small"
|
||||
strokeColor={getConfidenceColor(ocr)}
|
||||
showInfo={false}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{t("bills.labels.ai.confidence.match")}:</strong> {jobMatch.toFixed(1)}%
|
||||
<Progress
|
||||
percent={jobMatch}
|
||||
size="small"
|
||||
strokeColor={getConfidenceColor(jobMatch)}
|
||||
showInfo={false}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<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("bills.labels.ai.confidence.missing_data")}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag color={color} style={{ margin: 0, cursor: "help", userSelect: "none" }}>
|
||||
{total.toFixed(0)}%
|
||||
</Tag>
|
||||
</Space>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfidenceDisplay;
|
||||
@@ -19,13 +19,12 @@ export default function ChatTagRoComponent({ roOptions, loading, handleSearch, h
|
||||
placeholder={t("general.labels.search")}
|
||||
onSelect={handleInsertTag}
|
||||
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
|
||||
>
|
||||
{roOptions.map((item, idx) => (
|
||||
<Select.Option key={item.id || idx}>
|
||||
{` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={roOptions.map((item, idx) => ({
|
||||
key: item.id || idx,
|
||||
value: item.id || idx,
|
||||
label: ` ${item.ro_number || ""} | ${OwnerNameDisplayFunction(item)}`
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
{loading ? <LoadingOutlined /> : null}
|
||||
|
||||
|
||||
@@ -309,13 +309,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.md_ins_cos.map((s) => ({
|
||||
key: s.name,
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={"class"}
|
||||
@@ -327,13 +327,13 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_classes.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.md_classes.map((s) => ({
|
||||
key: s,
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("contracts.labels.convertform.applycleanupcharge")}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const ContractStatusComponent = ({ value, onChange, ref }) => {
|
||||
const [option, setOption] = useState(value);
|
||||
const { t } = useTranslation();
|
||||
@@ -15,11 +13,17 @@ const ContractStatusComponent = ({ value, onChange, ref }) => {
|
||||
}, [value, option, onChange]);
|
||||
|
||||
return (
|
||||
<Select ref={ref} value={option} style={{ width: 100 }} onChange={setOption}>
|
||||
<Option value="contracts.status.new">{t("contracts.status.new")}</Option>
|
||||
<Option value="contracts.status.out">{t("contracts.status.out")}</Option>
|
||||
<Option value="contracts.status.returned">{t("contracts.status.out")}</Option>
|
||||
</Select>
|
||||
<Select
|
||||
ref={ref}
|
||||
value={option}
|
||||
style={{ width: 100 }}
|
||||
onChange={setOption}
|
||||
options={[
|
||||
{ value: "contracts.status.new", label: t("contracts.status.new") },
|
||||
{ value: "contracts.status.out", label: t("contracts.status.out") },
|
||||
{ value: "contracts.status.returned", label: t("contracts.status.out") }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Select } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
|
||||
const [option, setOption] = useState(value);
|
||||
const { t } = useTranslation();
|
||||
@@ -23,10 +21,11 @@ const CourtesyCarReadinessComponent = ({ value, onChange, ref }) => {
|
||||
width: 100
|
||||
}}
|
||||
onChange={setOption}
|
||||
>
|
||||
<Option value="courtesycars.readiness.ready">{t("courtesycars.readiness.ready")}</Option>
|
||||
<Option value="courtesycars.readiness.notready">{t("courtesycars.readiness.notready")}</Option>
|
||||
</Select>
|
||||
options={[
|
||||
{ value: "courtesycars.readiness.ready", label: t("courtesycars.readiness.ready") },
|
||||
{ value: "courtesycars.readiness.notready", label: t("courtesycars.readiness.notready") }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default CourtesyCarReadinessComponent;
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
|
||||
const [option, setOption] = useState(value);
|
||||
const { t } = useTranslation();
|
||||
@@ -22,14 +20,15 @@ const CourtesyCarStatusComponent = ({ value, onChange, ref }) => {
|
||||
width: 100
|
||||
}}
|
||||
onChange={setOption}
|
||||
>
|
||||
<Option value="courtesycars.status.in">{t("courtesycars.status.in")}</Option>
|
||||
<Option value="courtesycars.status.inservice">{t("courtesycars.status.inservice")}</Option>
|
||||
<Option value="courtesycars.status.out">{t("courtesycars.status.out")}</Option>
|
||||
<Option value="courtesycars.status.sold">{t("courtesycars.status.sold")}</Option>
|
||||
<Option value="courtesycars.status.leasereturn">{t("courtesycars.status.leasereturn")}</Option>
|
||||
<Option value="courtesycars.status.unavailable">{t("courtesycars.status.unavailable")}</Option>
|
||||
</Select>
|
||||
options={[
|
||||
{ value: "courtesycars.status.in", label: t("courtesycars.status.in") },
|
||||
{ value: "courtesycars.status.inservice", label: t("courtesycars.status.inservice") },
|
||||
{ value: "courtesycars.status.out", label: t("courtesycars.status.out") },
|
||||
{ value: "courtesycars.status.sold", label: t("courtesycars.status.sold") },
|
||||
{ value: "courtesycars.status.leasereturn", label: t("courtesycars.status.leasereturn") },
|
||||
{ value: "courtesycars.status.unavailable", label: t("courtesycars.status.unavailable") }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default CourtesyCarStatusComponent;
|
||||
|
||||
@@ -272,11 +272,19 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
|
||||
name={[field.name, "name"]}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select style={{ width: "100%" }} onSelect={(value) => handlePayerSelect(value, index)}>
|
||||
{bodyshop.cdk_configuration?.payers?.map((payer) => (
|
||||
<Select.Option key={payer.name}>{payer.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
style={{ width: "100%" }}
|
||||
onSelect={(value) => handlePayerSelect(value, index)}
|
||||
options={bodyshop.cdk_configuration?.payers?.map((payer) => ({
|
||||
key: payer.name,
|
||||
value: payer.name,
|
||||
label: payer.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
|
||||
@@ -86,11 +86,13 @@ export function EmailOverlayComponent({ emailConfig, form, selectedMediaState, b
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option key={currentUser.email}>{currentUser.email}</Select.Option>
|
||||
<Select.Option key={bodyshop.email}>{bodyshop.email}</Select.Option>
|
||||
{bodyshop.md_from_emails && bodyshop.md_from_emails.map((e) => <Select.Option key={e}>{e}</Select.Option>)}
|
||||
</Select>
|
||||
<Select
|
||||
options={[
|
||||
{ key: currentUser.email, value: currentUser.email, label: currentUser.email },
|
||||
{ key: bodyshop.email, value: bodyshop.email, label: bodyshop.email },
|
||||
...(bodyshop.md_from_emails ? bodyshop.md_from_emails.map((e) => ({ key: e, value: e, label: e })) : [])
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Select, Space, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
//To be used as a form element only.
|
||||
|
||||
const EmployeeSearchSelectEmail = ({ options, ...props }) => {
|
||||
@@ -12,26 +11,24 @@ const EmployeeSearchSelectEmail = ({ options, ...props }) => {
|
||||
showSearch={{
|
||||
optionFilterProp: "search"
|
||||
}}
|
||||
// value={option}
|
||||
style={{
|
||||
width: 400
|
||||
}}
|
||||
options={options?.map((o) => ({
|
||||
key: o.id,
|
||||
value: o.user_email,
|
||||
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
|
||||
label: (
|
||||
<Space>
|
||||
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
|
||||
<Tag color="green">
|
||||
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
}))}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((o) => (
|
||||
<Option key={o.id} value={o.user_email} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
|
||||
<Space>
|
||||
{`${o.employee_number} ${o.first_name} ${o.last_name}`}
|
||||
|
||||
<Tag color="green">
|
||||
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default EmployeeSearchSelectEmail;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Select, Space, Tag } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
//To be used as a form element only.
|
||||
|
||||
const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
|
||||
@@ -12,30 +11,29 @@ const EmployeeSearchSelect = ({ options, showEmail, ...props }) => {
|
||||
showSearch={{
|
||||
optionFilterProp: "search"
|
||||
}}
|
||||
// value={option}
|
||||
style={{
|
||||
width: 400
|
||||
}}
|
||||
options={options?.map((o) => ({
|
||||
key: o.id,
|
||||
value: o.id,
|
||||
search: `${o.employee_number} ${o.first_name} ${o.last_name}`,
|
||||
label: (
|
||||
<Space size="small">
|
||||
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
|
||||
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
|
||||
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
|
||||
</Tag>
|
||||
{showEmail && o.user_email ? (
|
||||
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
|
||||
{o.user_email}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
)
|
||||
}))}
|
||||
{...props}
|
||||
>
|
||||
{options
|
||||
? options.map((o) => (
|
||||
<Option key={o.id} value={o.id} search={`${o.employee_number} ${o.first_name} ${o.last_name}`}>
|
||||
<Space size="small">
|
||||
{`${o.employee_number ?? ""} ${o.first_name} ${o.last_name}`}
|
||||
<Tag color="green" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
|
||||
{o.flat_rate ? t("timetickets.labels.flat_rate") : t("timetickets.labels.straight_time")}
|
||||
</Tag>
|
||||
{showEmail && o.user_email ? (
|
||||
<Tag color="blue" style={{ padding: "0.1 0.1rem", marginRight: "1px", marginLeft: "1px" }}>
|
||||
{o.user_email}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default EmployeeSearchSelect;
|
||||
|
||||
@@ -39,11 +39,13 @@ export default function FormsFieldChanged({ form, skipPrompt }) {
|
||||
{errors.length > 0 && (
|
||||
<AlertComponent
|
||||
type="error"
|
||||
title={
|
||||
message={t("general.labels.validationerror")}
|
||||
description={
|
||||
<div>
|
||||
<ul>{errors.map((e, idx) => e.errors.map((e2, idx2) => <li key={`${idx}${idx2}`}>{e2}</li>))}</ul>
|
||||
</div>
|
||||
}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -67,16 +67,19 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
|
||||
);
|
||||
};
|
||||
|
||||
const handleInsSelect = (value, option) => {
|
||||
form.setFieldsValue({
|
||||
addr1: option.obj.name,
|
||||
addr2: option.obj.street1,
|
||||
addr3: option.obj.street2,
|
||||
city: option.obj.city,
|
||||
state: option.obj.state,
|
||||
zip: option.obj.zip,
|
||||
vendorid: null
|
||||
});
|
||||
const handleInsSelect = (value) => {
|
||||
const selectedVendor = bodyshop.md_ins_cos.find(s => s.name === value);
|
||||
if (selectedVendor) {
|
||||
form.setFieldsValue({
|
||||
addr1: selectedVendor.name,
|
||||
addr2: selectedVendor.street1,
|
||||
addr3: selectedVendor.street2,
|
||||
city: selectedVendor.city,
|
||||
state: selectedVendor.state,
|
||||
zip: selectedVendor.zip,
|
||||
vendorid: null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleVendorSelect = (vendorid) => {
|
||||
@@ -103,13 +106,13 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
|
||||
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("bodyshop.fields.md_ins_co.name")} name="ins_co_id">
|
||||
<Select onSelect={handleInsSelect}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} obj={s} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
onSelect={handleInsSelect}
|
||||
options={bodyshop.md_ins_cos.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item label={t("printcenter.jobs.3rdpartyfields.addr1")} name="addr1">
|
||||
|
||||
@@ -88,17 +88,15 @@ export function JoblineBulkAssign({ setSelectedLines, selectedLines, insertAudit
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{bodyshop.employee_teams.map((team) => (
|
||||
<Select.Option value={team.id} key={team.id} name={team.name}>
|
||||
{team.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.employee_teams.map((team) => ({
|
||||
value: team.id,
|
||||
label: team.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
|
||||
@@ -122,22 +122,26 @@ export function JobLineConvertToLabor({
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear showSearch={{ optionFilterProp: "children" }}>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item shouldUpdate>
|
||||
|
||||
@@ -115,19 +115,18 @@ export function JobLineDispatchButton({
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: "label",
|
||||
filterOption: (input, option) => option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{bodyshop.employees
|
||||
options={bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
.map((emp) => ({
|
||||
value: emp.id,
|
||||
key: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
|
||||
@@ -64,13 +64,12 @@ export function JobLineStatusPopup({ bodyshop, jobline, disabled }) {
|
||||
onSelect={handleChange}
|
||||
onBlur={handleSave}
|
||||
onClear={() => handleChange(null)}
|
||||
>
|
||||
{Object.values(bodyshop.md_order_statuses).map((s, idx) => (
|
||||
<Select.Option key={idx} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={Object.values(bodyshop.md_order_statuses).map((s, idx) => ({
|
||||
key: idx,
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</LoadingSpinner>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -75,13 +75,12 @@ export function JoblineTeamAssignment({ bodyshop, jobline, disabled, jobId, inse
|
||||
onSelect={handleChange}
|
||||
onBlur={handleSave}
|
||||
onClear={() => handleChange(null)}
|
||||
>
|
||||
{Object.values(bodyshop.employee_teams).map((s, idx) => (
|
||||
<Select.Option key={idx} value={s.id}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={Object.values(bodyshop.employee_teams).map((s) => ({
|
||||
key: s.id,
|
||||
value: s.id,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</LoadingSpinner>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,22 +67,22 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item label={t("joblines.fields.mod_lbr_ty")} name="mod_lbr_ty">
|
||||
<Select allowClear>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select allowClear options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("joblines.fields.op_code_desc")} name="op_code_desc">
|
||||
<Input />
|
||||
@@ -128,17 +128,17 @@ export function JobLinesUpsertModalComponent({ bodyshop, open, jobLine, handleCa
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow>
|
||||
<Form.Item label={t("joblines.fields.part_type")} name="part_type">
|
||||
<Select allowClear>
|
||||
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
|
||||
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
|
||||
<Select.Option value="PAE">{t("joblines.fields.part_types.PAE")}</Select.Option>
|
||||
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
|
||||
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
|
||||
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
|
||||
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
|
||||
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
|
||||
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
|
||||
</Select>
|
||||
<Select allowClear options={[
|
||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("joblines.fields.oem_partno")} name="oem_partno">
|
||||
<Input />
|
||||
|
||||
@@ -8,8 +8,6 @@ import { SEARCH_JOBS_BY_ID_FOR_AUTOCOMPLETE, SEARCH_JOBS_FOR_AUTOCOMPLETE } from
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const JobSearchSelect = ({
|
||||
disabled,
|
||||
convertedOnly = false,
|
||||
@@ -87,24 +85,24 @@ const JobSearchSelect = ({
|
||||
style={{ width: "100%" }}
|
||||
suffixIcon={(loading || idLoading) && <Spin />} // matches OLD spinner semantics
|
||||
notFoundContent={loading ? <LoadingOutlined /> : null} // matches OLD (loading only)
|
||||
>
|
||||
{theOptions
|
||||
? theOptions.map((o) => (
|
||||
<Option key={o.id} value={o.id} status={o.status}>
|
||||
<Space align="center">
|
||||
<span>
|
||||
{`${clm_no && o.clm_no ? `${o.clm_no} | ` : ""}${o.ro_number || t("general.labels.na")} | ${OwnerNameDisplayFunction(
|
||||
o
|
||||
)} | ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""}`}
|
||||
</span>
|
||||
<Tag>
|
||||
<strong>{o.status}</strong>
|
||||
</Tag>
|
||||
</Space>
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
options={theOptions?.map((o) => ({
|
||||
key: o.id,
|
||||
value: o.id,
|
||||
status: o.status,
|
||||
label: (
|
||||
<Space align="center">
|
||||
<span>
|
||||
{`${clm_no && o.clm_no ? `${o.clm_no} | ` : ""}${o.ro_number || t("general.labels.na")} | ${OwnerNameDisplayFunction(
|
||||
o
|
||||
)} | ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""}`}
|
||||
</span>
|
||||
<Tag>
|
||||
<strong>{o.status}</strong>
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
}))}
|
||||
/>
|
||||
|
||||
{error ? <AlertComponent title={error.message} type="error" /> : null}
|
||||
{idError ? <AlertComponent title={idError.message} type="error" /> : null}
|
||||
|
||||
@@ -59,13 +59,12 @@ export function JobsAdminClass({ bodyshop, job }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_classes.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.md_classes.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -141,13 +141,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
disabled={jobRO}
|
||||
>
|
||||
{bodyshop.md_responsibility_centers.profits.map((p) => (
|
||||
<Select.Option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||
value: p.name,
|
||||
label: p.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</td>
|
||||
<td>
|
||||
@@ -171,13 +169,11 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
|
||||
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
}}
|
||||
disabled={jobRO}
|
||||
>
|
||||
{bodyshop.md_responsibility_centers.profits.map((p) => (
|
||||
<Select.Option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.md_responsibility_centers.profits.map((p) => ({
|
||||
value: p.name,
|
||||
label: p.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation } from "@apollo/client/react";
|
||||
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
|
||||
import axios from "axios";
|
||||
import { some } from "lodash";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
@@ -19,10 +19,10 @@ import { useSocket } from "../../contexts/SocketIO/useSocket.js";
|
||||
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
//currentUser: selectCurrentUser
|
||||
bodyshop: selectBodyshop,
|
||||
jobRO: selectJobReadOnly
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
insertAuditTrail: ({ jobid, operation, type }) =>
|
||||
dispatch(
|
||||
@@ -37,16 +37,17 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id); // Track early RO creation state
|
||||
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false); // Track if created in THIS modal session
|
||||
|
||||
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
|
||||
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false);
|
||||
|
||||
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const notification = useNotification();
|
||||
const allFormValues = Form.useWatch([], form);
|
||||
const { socket } = useSocket(); // Extract socket from context
|
||||
const { socket } = useSocket();
|
||||
|
||||
// Get Fortellis treatment for proper DMS mode detection
|
||||
const {
|
||||
treatments: { Fortellis }
|
||||
} = useTreatmentsWithConfig({
|
||||
@@ -55,16 +56,64 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
splitKey: bodyshop?.imexshopid
|
||||
});
|
||||
|
||||
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
|
||||
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
|
||||
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
|
||||
|
||||
const insuranceOptions = useMemo(
|
||||
() =>
|
||||
(bodyshop?.md_ins_cos ?? []).map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
})),
|
||||
[bodyshop?.md_ins_cos]
|
||||
);
|
||||
|
||||
const classOptions = useMemo(
|
||||
() =>
|
||||
(bodyshop?.md_classes ?? []).map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
})),
|
||||
[bodyshop?.md_classes]
|
||||
);
|
||||
|
||||
const referralOptions = useMemo(
|
||||
() =>
|
||||
(bodyshop?.md_referral_sources ?? []).map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
})),
|
||||
[bodyshop?.md_referral_sources]
|
||||
);
|
||||
|
||||
const csrOptions = useMemo(
|
||||
() =>
|
||||
(bodyshop?.employees ?? [])
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => ({
|
||||
value: emp.id,
|
||||
label: `${emp.first_name} ${emp.last_name}`
|
||||
})),
|
||||
[bodyshop?.employees]
|
||||
);
|
||||
|
||||
const categoryOptions = useMemo(
|
||||
() =>
|
||||
(bodyshop?.md_categories ?? []).map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
})),
|
||||
[bodyshop?.md_categories]
|
||||
);
|
||||
|
||||
const handleConvert = async ({ employee_csr, category, ...values }) => {
|
||||
if (parentFormIsFieldsTouched()) {
|
||||
alert(t("jobs.labels.savebeforeconversion"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await mutationConvertJob({
|
||||
variables: {
|
||||
jobId: job.id,
|
||||
@@ -78,13 +127,11 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
});
|
||||
|
||||
if (values.ca_gst_registrant) {
|
||||
await axios.post("/job/totalsssu", {
|
||||
id: job.id
|
||||
});
|
||||
await axios.post("/job/totalsssu", { id: job.id });
|
||||
}
|
||||
|
||||
if (!res.errors) {
|
||||
refetch();
|
||||
refetch?.();
|
||||
notification.success({
|
||||
title: t("jobs.successes.converted")
|
||||
});
|
||||
@@ -97,19 +144,20 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
|
||||
|
||||
const handleEarlyROSuccess = (result) => {
|
||||
setEarlyRoCreated(true); // Mark early RO as created
|
||||
setEarlyRoCreatedThisSession(true); // Mark as created in this session
|
||||
setEarlyRoCreated(true);
|
||||
setEarlyRoCreatedThisSession(true);
|
||||
notification.success({
|
||||
title: t("jobs.successes.early_ro_created"),
|
||||
description: `RO Number: ${result.roNumber || "N/A"}`
|
||||
});
|
||||
// Delay refetch to keep success message visible for 2 seconds
|
||||
|
||||
setTimeout(() => {
|
||||
refetch?.();
|
||||
}, 2000);
|
||||
@@ -130,29 +178,28 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
disabled={job.converted || jobRO}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job
|
||||
setEarlyRoCreatedThisSession(false); // Reset session state when opening modal
|
||||
setEarlyRoCreated(!!job?.dms_id);
|
||||
setEarlyRoCreatedThisSession(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
|
||||
{/* Convert Job Modal */}
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleModalClose}
|
||||
closable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
|
||||
maskClosable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
|
||||
closable={!(earlyRoCreatedThisSession && !job.converted)}
|
||||
maskClosable={!(earlyRoCreatedThisSession && !job.converted)}
|
||||
title={t("jobs.actions.convert")}
|
||||
footer={null}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
>
|
||||
{/* Standard Convert Form */}
|
||||
<Form
|
||||
layout="vertical"
|
||||
form={form}
|
||||
preserve={false}
|
||||
onFinish={handleConvert}
|
||||
initialValues={{
|
||||
driveable: true,
|
||||
@@ -164,7 +211,6 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
referral_source_extra: job.referral_source_extra ?? ""
|
||||
}}
|
||||
>
|
||||
{/* Show Reynolds Early RO section at the top if applicable */}
|
||||
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
|
||||
<>
|
||||
<RREarlyROForm
|
||||
@@ -181,127 +227,78 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
<Form.Item
|
||||
name={["ins_co_nm"]}
|
||||
label={t("jobs.fields.ins_co_nm")}
|
||||
rules={[
|
||||
{
|
||||
required: true
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_ins_cos.map((s, i) => (
|
||||
<Select.Option key={i} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp:'label'
|
||||
}}
|
||||
options={insuranceOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{bodyshop.enforce_class && (
|
||||
<Form.Item
|
||||
name={"class"}
|
||||
label={t("jobs.fields.class")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_class
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_classes.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Form.Item name="class" label={t("jobs.fields.class")} rules={[{ required: bodyshop.enforce_class }]}>
|
||||
<Select options={classOptions} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{bodyshop.enforce_referral && (
|
||||
<>
|
||||
<Form.Item
|
||||
name={"referral_source"}
|
||||
name="referral_source"
|
||||
label={t("jobs.fields.referralsource")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_referral
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: bodyshop.enforce_referral }]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={referralOptions} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bodyshop.enforce_conversion_csr && (
|
||||
<Form.Item
|
||||
name={"employee_csr"}
|
||||
name="employee_csr"
|
||||
label={t(
|
||||
InstanceRenderManager({
|
||||
imex: "jobs.fields.employee_csr",
|
||||
rome: "jobs.fields.employee_csr_writer"
|
||||
})
|
||||
)}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_csr
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
rules={[{ required: bodyshop.enforce_conversion_csr }]}
|
||||
>
|
||||
<Select
|
||||
showSearch={{
|
||||
optionFilterProp: "children",
|
||||
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
||||
optionFilterProp: 'label',
|
||||
filterOption: (input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}}
|
||||
style={{ width: 200 }}
|
||||
>
|
||||
{bodyshop.employees
|
||||
.filter((emp) => emp.active)
|
||||
.map((emp) => (
|
||||
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
|
||||
{`${emp.first_name} ${emp.last_name}`}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
options={csrOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{bodyshop.enforce_conversion_category && (
|
||||
<Form.Item
|
||||
name={"category"}
|
||||
label={t("jobs.fields.category")}
|
||||
rules={[
|
||||
{
|
||||
required: bodyshop.enforce_conversion_category
|
||||
//message: t("general.validation.required"),
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear>
|
||||
{bodyshop.md_categories.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Form.Item name="category" label={t("jobs.fields.category")} rules={[{ required: bodyshop.enforce_conversion_category }]}>
|
||||
<Select allowClear options={categoryOptions} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
|
||||
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
@@ -316,6 +313,7 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
|
||||
>
|
||||
{t("jobs.actions.convert")}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
|
||||
{t("general.actions.close")}
|
||||
</Button>
|
||||
|
||||
@@ -60,13 +60,13 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
onChange={handleInsCoChange}
|
||||
options={bodyshop.md_ins_cos.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input />
|
||||
@@ -192,13 +192,12 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referralsource")} name="referral_source">
|
||||
<Select>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.md_referral_sources.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input />
|
||||
@@ -221,10 +220,13 @@ export function JobsCreateJobsInfo({ bodyshop, form, selected }) {
|
||||
<CurrencyInput min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
||||
<Select allowClear>
|
||||
<Select.Option value="W">{t("jobs.labels.deductible.waived")}</Select.Option>
|
||||
<Select.Option value="Y">{t("jobs.labels.deductible.stands")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.depreciation_taxes")} name="depreciation_taxes">
|
||||
<CurrencyInput />
|
||||
|
||||
@@ -43,20 +43,19 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_status")} name="ded_status">
|
||||
<Select disabled={jobRO}>
|
||||
<Select.Option value="W">{t("jobs.labels.deductible.waived")}</Select.Option>
|
||||
<Select.Option value="Y">{t("jobs.labels.deductible.stands")}</Select.Option>
|
||||
</Select>
|
||||
<Select disabled={jobRO} options={[
|
||||
{ value: "W", label: t("jobs.labels.deductible.waived") },
|
||||
{ value: "Y", label: t("jobs.labels.deductible.stands") }
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_amt")} name="ded_amt">
|
||||
<CurrencyInput disabled={jobRO} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ded_note")} name="ded_note">
|
||||
<Select disabled={jobRO}>
|
||||
{bodyshop.md_ded_notes.map((n, index) => (
|
||||
<Select.Option key={index}>{n}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={jobRO} options={bodyshop.md_ded_notes.map((n) => ({
|
||||
value: n,
|
||||
label: n
|
||||
}))} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.policy_no")} name="policy_no">
|
||||
<Input disabled={jobRO} />
|
||||
@@ -66,13 +65,10 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t("jobs.fields.ins_co_nm")} name="ins_co_nm">
|
||||
<Select disabled={jobRO} onChange={handleInsCoChange}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={jobRO} onChange={handleInsCoChange} options={bodyshop.md_ins_cos.map((s) => ({
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.ins_addr1")} name="ins_addr1">
|
||||
<Input disabled={jobRO} />
|
||||
@@ -123,25 +119,19 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={jobRO} allowClear>
|
||||
{bodyshop.md_referral_sources.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.md_referral_sources.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
|
||||
<Input disabled={jobRO} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.alt_transport")} name="alt_transport">
|
||||
<Select disabled={jobRO} allowClear>
|
||||
{bodyshop.appt_alt_transport.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.appt_alt_transport.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
</Form.Item>
|
||||
</FormRow>
|
||||
<Row gutter={[16, 16]}>
|
||||
@@ -243,13 +233,10 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
|
||||
</FormRow>
|
||||
<FormRow header={t("jobs.forms.other")}>
|
||||
<Form.Item label={t("jobs.fields.category")} name="category">
|
||||
<Select disabled={jobRO} allowClear>
|
||||
{bodyshop.md_categories.map((s) => (
|
||||
<Select.Option key={s} value={s}>
|
||||
{s}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={jobRO} allowClear options={bodyshop.md_categories.map((s) => ({
|
||||
value: s,
|
||||
label: s
|
||||
}))} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
|
||||
<Input disabled={jobRO} />
|
||||
|
||||
@@ -714,13 +714,12 @@ export function JobsDetailHeaderActions({
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.color")} name="color">
|
||||
<Select>
|
||||
{bodyshop.appt_colors.map((col, idx) => (
|
||||
<Select.Option key={idx} value={col.color.hex}>
|
||||
{col.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.appt_colors.map((col) => ({
|
||||
value: col.color.hex,
|
||||
label: col.label
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
|
||||
@@ -94,22 +94,26 @@ export function LaborAllocationsAdjustmentEdit({
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select allowClear disabled={!!mod_lbr_ty}>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
disabled={!!mod_lbr_ty}
|
||||
options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.adjustmenthours")}
|
||||
|
||||
@@ -7,8 +7,6 @@ import { SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE, SEARCH_OWNERS_FOR_AUTOCOMPLETE }
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
const [callSearch, { loading, error, data }] = useLazyQuery(SEARCH_OWNERS_FOR_AUTOCOMPLETE);
|
||||
|
||||
@@ -71,15 +69,12 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
onSelect={handleSelect}
|
||||
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{theOptions
|
||||
? theOptions.map((o) => (
|
||||
<Option key={o.id} value={o.id}>
|
||||
{`${OwnerNameDisplayFunction(o)} | ${o.ownr_addr1 || ""} `}
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
options={theOptions?.map((o) => ({
|
||||
key: o.id,
|
||||
value: o.id,
|
||||
label: `${OwnerNameDisplayFunction(o)} | ${o.ownr_addr1 || ""} `
|
||||
}))}
|
||||
/>
|
||||
{idLoading || loading ? <LoadingOutlined /> : null}
|
||||
{error ? <AlertComponent title={error.message} type="error" /> : null}
|
||||
{idError ? <AlertComponent title={idError.message} type="error" /> : null}
|
||||
|
||||
@@ -187,6 +187,7 @@ export function PartsOrderListTableDrawerComponent({
|
||||
actions: { refetch: refetch },
|
||||
context: {
|
||||
job: job,
|
||||
parts_order: { id: record.id },
|
||||
bill: {
|
||||
vendorid: record.vendor.id,
|
||||
is_credit_memo: record.return,
|
||||
|
||||
@@ -162,6 +162,7 @@ export function PartsOrderListTableComponent({
|
||||
actions: { refetch: refetch },
|
||||
context: {
|
||||
job: job,
|
||||
parts_order: { id: record.id },
|
||||
bill: {
|
||||
vendorid: record.vendor.id,
|
||||
is_credit_memo: record.return,
|
||||
|
||||
@@ -158,19 +158,21 @@ export function PartsOrderModalComponent({
|
||||
key={`${index}part_type`}
|
||||
name={[field.name, "part_type"]}
|
||||
>
|
||||
<Select disabled={!(sendType === "oec" && OEConnection_PriceChange.treatment === "on")}>
|
||||
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
|
||||
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
|
||||
|
||||
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
|
||||
<Select.Option value="PAG">{t("joblines.fields.part_types.PAG")}</Select.Option>
|
||||
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
|
||||
<Select.Option value="PAP">{t("joblines.fields.part_types.PAP")}</Select.Option>
|
||||
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
|
||||
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
|
||||
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
|
||||
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
disabled={!(sendType === "oec" && OEConnection_PriceChange.treatment === "on")}
|
||||
options={[
|
||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAG", label: t("joblines.fields.part_types.PAG") },
|
||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAP", label: t("joblines.fields.part_types.PAP") },
|
||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.oem_partno")}
|
||||
|
||||
@@ -29,13 +29,12 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
})
|
||||
});
|
||||
}}
|
||||
>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
options={bodyshop.md_parts_locations.map((loc, idx) => ({
|
||||
key: idx,
|
||||
value: loc,
|
||||
label: loc
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Typography.Title level={4}>{t("parts_orders.labels.inthisorder")}</Typography.Title>
|
||||
@@ -85,13 +84,14 @@ export function PartsReceiveModalComponent({ bodyshop, form }) {
|
||||
key={`${index}location`}
|
||||
name={[field.name, "location"]}
|
||||
>
|
||||
<Select style={{ width: "10rem" }}>
|
||||
{bodyshop.md_parts_locations.map((loc, idx) => (
|
||||
<Select.Option key={idx} value={loc}>
|
||||
{loc}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
style={{ width: "10rem" }}
|
||||
options={bodyshop.md_parts_locations.map((loc, idx) => ({
|
||||
key: idx,
|
||||
value: loc,
|
||||
label: loc
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("parts_orders.fields.quantity")}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Button, Card, Divider, Form, Input, Select, Space } from "antd";
|
||||
import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
export default function PartsShopInfoEmailPresets() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -26,13 +24,7 @@ export default function PartsShopInfoEmailPresets() {
|
||||
label={t("bodyshop.labels.email_type")}
|
||||
rules={[{ required: true, message: t("bodyshop.errors.email_type_required") }]}
|
||||
>
|
||||
<Select placeholder={t("bodyshop.placeholders.select_email_type")}>
|
||||
{emailTypes.map((type) => (
|
||||
<Option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select placeholder={t("bodyshop.placeholders.select_email_type")} options={emailTypes} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
|
||||
@@ -91,20 +91,25 @@ export function PaymentFormComponent({ form, bodyshop, disabled }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={disabled}>
|
||||
<Select.Option value={t("payments.labels.customer")}>{t("payments.labels.customer")}</Select.Option>
|
||||
{Qb_Multi_Ar.treatment === "on" ? (
|
||||
<Select.OptGroup label={t("payments.labels.external")}>
|
||||
{bodyshop.md_ins_cos.map((i, idx) => (
|
||||
<Select.Option key={idx} value={i.name}>
|
||||
{i.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
) : (
|
||||
<Select.Option value={t("payments.labels.insurance")}>{t("payments.labels.insurance")}</Select.Option>
|
||||
)}
|
||||
</Select>
|
||||
<Select disabled={disabled}
|
||||
options={Qb_Multi_Ar.treatment === "on"
|
||||
? [
|
||||
{ value: t("payments.labels.customer"), label: t("payments.labels.customer") },
|
||||
{
|
||||
label: t("payments.labels.external"),
|
||||
options: bodyshop.md_ins_cos.map((i, idx) => ({
|
||||
key: idx,
|
||||
value: i.name,
|
||||
label: i.name
|
||||
}))
|
||||
}
|
||||
]
|
||||
: [
|
||||
{ value: t("payments.labels.customer"), label: t("payments.labels.customer") },
|
||||
{ value: t("payments.labels.insurance"), label: t("payments.labels.insurance") }
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -117,13 +122,13 @@ export function PaymentFormComponent({ form, bodyshop, disabled }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select disabled={disabled}>
|
||||
{bodyshop.md_payment_types.map((v, idx) => (
|
||||
<Select.Option key={idx} value={v}>
|
||||
{v}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select disabled={disabled}
|
||||
options={bodyshop.md_payment_types.map((v, idx) => ({
|
||||
key: idx,
|
||||
value: v,
|
||||
label: v
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow>
|
||||
|
||||
@@ -453,10 +453,10 @@ export function ProductionListConfigManager({
|
||||
}}
|
||||
onSelect={handleSelect}
|
||||
placeholder={t("production.labels.selectview")}
|
||||
optionLabelProp="label"
|
||||
popupMatchSelectWidth={false}
|
||||
value={activeView}
|
||||
disabled={open || isAddingNewProfile} // Disable the Select box when the popover is open or adding a new profile
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{bodyshop?.production_config &&
|
||||
bodyshop.production_config
|
||||
|
||||
@@ -158,20 +158,28 @@ export function ScheduleJobModalComponent({
|
||||
</LayoutFormRow>
|
||||
<LayoutFormRow grow>
|
||||
<Form.Item name="color" label={t("appointments.fields.color")}>
|
||||
<Select allowClear>
|
||||
{bodyshop.appt_colors &&
|
||||
bodyshop.appt_colors.map((color) => (
|
||||
<Select.Option style={{ color: color.color.hex }} key={color.color.hex} value={color.color.hex}>
|
||||
{color.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={
|
||||
bodyshop.appt_colors &&
|
||||
bodyshop.appt_colors.map((color) => ({
|
||||
value: color.color.hex,
|
||||
label: color.label
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={"alt_transport"} label={t("jobs.fields.alt_transport")}>
|
||||
<Select allowClear>
|
||||
{bodyshop.appt_alt_transport &&
|
||||
bodyshop.appt_alt_transport.map((alt) => <Select.Option key={alt}>{alt}</Select.Option>)}
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={
|
||||
bodyshop.appt_alt_transport &&
|
||||
bodyshop.appt_alt_transport.map((alt) => ({
|
||||
value: alt,
|
||||
label: alt
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={"note"} label={t("appointments.fields.note")}>
|
||||
<Input />
|
||||
|
||||
@@ -120,13 +120,12 @@ export function ScheduleManualEvent({ bodyshop, event }) {
|
||||
<FormDateTimePickerComponent />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("appointments.fields.color")} name="color">
|
||||
<Select>
|
||||
{bodyshop.appt_colors.map((col, idx) => (
|
||||
<Select.Option key={idx} value={col.color.hex}>
|
||||
{col.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.appt_colors.map((col) => ({
|
||||
value: col.color.hex,
|
||||
label: col.label
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
|
||||
@@ -325,22 +325,20 @@ export function ShopEmployeesFormComponent({ bodyshop }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option key={"shift"} value="timetickets.labels.shift">
|
||||
{t("timetickets.labels.shift")}
|
||||
</Select.Option>
|
||||
|
||||
{bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? CiecaSelect(false, true)
|
||||
: bodyshop.md_responsibility_centers.costs.map((c) => (
|
||||
<Select.Option key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") },
|
||||
...(bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? CiecaSelect(false, true)
|
||||
: bodyshop.md_responsibility_centers.costs.map((c) => ({
|
||||
value: c.name,
|
||||
label: c.name
|
||||
})))
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("employees.fields.rate")}
|
||||
|
||||
@@ -1039,22 +1039,25 @@ export function ShopInfoGeneral({ form }) {
|
||||
key={`${index}mod_lbr_ty`}
|
||||
name={[field.name, "mod_lbr_ty"]}
|
||||
>
|
||||
<Select allowClear>
|
||||
<Select.Option value="LAA">{t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "LAA", label: t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: t("joblines.fields.lbr_types.LA4") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.mod_lb_hrs")}
|
||||
@@ -1068,17 +1071,20 @@ export function ShopInfoGeneral({ form }) {
|
||||
key={`${index}part_type`}
|
||||
name={[field.name, "part_type"]}
|
||||
>
|
||||
<Select allowClear>
|
||||
<Select.Option value="PAA">{t("joblines.fields.part_types.PAA")}</Select.Option>
|
||||
<Select.Option value="PAC">{t("joblines.fields.part_types.PAC")}</Select.Option>
|
||||
<Select.Option value="PAE">{t("joblines.fields.part_types.PAE")}</Select.Option>
|
||||
<Select.Option value="PAL">{t("joblines.fields.part_types.PAL")}</Select.Option>
|
||||
<Select.Option value="PAM">{t("joblines.fields.part_types.PAM")}</Select.Option>
|
||||
<Select.Option value="PAN">{t("joblines.fields.part_types.PAN")}</Select.Option>
|
||||
<Select.Option value="PAO">{t("joblines.fields.part_types.PAO")}</Select.Option>
|
||||
<Select.Option value="PAR">{t("joblines.fields.part_types.PAR")}</Select.Option>
|
||||
<Select.Option value="PAS">{t("joblines.fields.part_types.PAS")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "PAA", label: t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAE", label: t("joblines.fields.part_types.PAE") },
|
||||
{ value: "PAL", label: t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAM", label: t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAN", label: t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: t("joblines.fields.part_types.PAS") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("joblines.fields.oem_partno")}
|
||||
|
||||
@@ -51,13 +51,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{Object.keys(ConfigFormTypes).map((i) => (
|
||||
<Select.Option key={i} value={i}>
|
||||
{i}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("jobs.fields.intake.label")}
|
||||
@@ -156,13 +150,13 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{Object.keys(TemplateListGenerated).map((i) => (
|
||||
<Select.Option key={TemplateListGenerated[i].key} value={TemplateListGenerated[i].key}>
|
||||
{TemplateListGenerated[i].title}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={Object.keys(TemplateListGenerated).map((i) => ({
|
||||
value: TemplateListGenerated[i].key,
|
||||
label: TemplateListGenerated[i].title
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["intakechecklist", "next_contact_hours"]}
|
||||
@@ -205,13 +199,7 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{Object.keys(ConfigFormTypes).map((i) => (
|
||||
<Select.Option key={i} value={i}>
|
||||
{i}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={Object.keys(ConfigFormTypes).map((i) => ({ value: i, label: i }))} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -310,13 +298,13 @@ export default function ShopInfoIntakeChecklistComponent({ form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{Object.keys(TemplateListGenerated).map((i) => (
|
||||
<Select.Option key={TemplateListGenerated[i].key} value={TemplateListGenerated[i].key}>
|
||||
{TemplateListGenerated[i].title}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={Object.keys(TemplateListGenerated).map((i) => ({
|
||||
value: TemplateListGenerated[i].key,
|
||||
label: TemplateListGenerated[i].title
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["deliverchecklist", "actual_delivery"]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,13 +80,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "pre_production_statuses"]}
|
||||
@@ -99,13 +93,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "production_statuses"]}
|
||||
@@ -118,13 +106,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "post_production_statuses"]}
|
||||
@@ -137,13 +119,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "ready_statuses"]}
|
||||
@@ -156,13 +132,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={["md_ro_statuses", "additional_board_statuses"]}
|
||||
@@ -175,13 +145,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select mode="multiple" options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<LayoutFormRow noDivider>
|
||||
<Form.Item
|
||||
@@ -194,13 +158,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_scheduled"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_arrived")}
|
||||
@@ -212,13 +170,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_arrived"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_exported")}
|
||||
@@ -230,13 +182,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_exported"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_imported")}
|
||||
@@ -248,13 +194,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_imported"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_invoiced")}
|
||||
@@ -266,13 +206,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_invoiced"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_completed")}
|
||||
@@ -284,13 +218,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_completed"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_delivered")}
|
||||
@@ -302,13 +230,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_delivered"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.statuses.default_void")}
|
||||
@@ -320,13 +242,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
]}
|
||||
name={["md_ro_statuses", "default_void"]}
|
||||
>
|
||||
<Select>
|
||||
{options.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={options.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
{Production_List_Status_Colors.treatment === "on" && (
|
||||
@@ -352,13 +268,7 @@ export function ShopInfoROStatusComponent({ bodyshop, form }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{productionStatus.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select options={productionStatus.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<DeleteFilled
|
||||
onClick={() => {
|
||||
|
||||
@@ -60,13 +60,13 @@ export default function ShopInfoSpeedPrint() {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select mode="multiple">
|
||||
{Object.keys(TemplateListGenerated).map((key, idx) => (
|
||||
<Select.Option key={idx} value={TemplateListGenerated[key].key}>
|
||||
{TemplateListGenerated[key].title}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={Object.keys(TemplateListGenerated).map((key) => ({
|
||||
value: TemplateListGenerated[key].key,
|
||||
label: TemplateListGenerated[key].title
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Space wrap>
|
||||
|
||||
@@ -43,85 +43,43 @@ export function ShopInfoIntellipay({ bodyshop, form }) {
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.visa")}
|
||||
name={["intellipay_config", "payment_map", "visa"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.mast")}
|
||||
name={["intellipay_config", "payment_map", "mast"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.amex")}
|
||||
name={["intellipay_config", "payment_map", "amex"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.disc")}
|
||||
name={["intellipay_config", "payment_map", "disc"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.dnrs")}
|
||||
name={["intellipay_config", "payment_map", "dnrs"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.jcb")}
|
||||
name={["intellipay_config", "payment_map", "jcb"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("bodyshop.fields.intellipay_config.payment_map.intr")}
|
||||
name={["intellipay_config", "payment_map", "intr"]}
|
||||
>
|
||||
<Select showSearch>
|
||||
{bodyshop.md_payment_types.map((item, idx) => (
|
||||
<Select.Option key={idx} value={item}>
|
||||
{item}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select showSearch options={bodyshop.md_payment_types.map((item) => ({ value: item, label: item }))} />
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
</>
|
||||
|
||||
@@ -57,21 +57,23 @@ export function TechClockInComponent({ form, bodyshop, technician }) {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{emps &&
|
||||
emps.rates.map((item) => (
|
||||
<Select.Option key={item.cost_center} value={item.cost_center}>
|
||||
{item.cost_center === "timetickets.labels.shift"
|
||||
<Select
|
||||
options={
|
||||
emps &&
|
||||
emps.rates.map((item) => ({
|
||||
value: item.cost_center,
|
||||
label:
|
||||
item.cost_center === "timetickets.labels.shift"
|
||||
? t(item.cost_center)
|
||||
: bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
: item.cost_center
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
<Divider />
|
||||
|
||||
@@ -201,22 +201,22 @@ export function TechClockOffButton({
|
||||
}
|
||||
]}
|
||||
>
|
||||
<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)
|
||||
: hasDmsKey
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center}
|
||||
</Select.Option>
|
||||
))
|
||||
)}
|
||||
</Select>
|
||||
<Select disabled={isShiftTicket}
|
||||
options={
|
||||
isShiftTicket
|
||||
? [{ value: "timetickets.labels.shift", label: t("timetickets.labels.shift") }]
|
||||
: emps &&
|
||||
emps.rates.map((item) => ({
|
||||
value: item.cost_center,
|
||||
label:
|
||||
item.cost_center === "timetickets.labels.shift"
|
||||
? t(item.cost_center)
|
||||
: hasDmsKey
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{isShiftTicket ? (
|
||||
@@ -232,11 +232,12 @@ export function TechClockOffButton({
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
{bodyshop.md_ro_statuses.production_statuses.map((item) => (
|
||||
<Select.Option key={item}></Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
options={bodyshop.md_ro_statuses.production_statuses.map((item) => ({
|
||||
value: item,
|
||||
label: item
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
|
||||
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
|
||||
import TimeTicketList from "../time-ticket-list/time-ticket-list.component";
|
||||
import JobEmployeeAssignmentsContainer from "./../job-employee-assignments/job-employee-assignments.container";
|
||||
import { PayrollLaborAllocationsTable } from "../labor-allocations-table/labor-allocations-table.payroll.component.jsx";
|
||||
|
||||
const mapStateToProps = createStructuredSelector({
|
||||
bodyshop: selectBodyshop,
|
||||
@@ -49,7 +50,7 @@ export function TimeTicketModalComponent({
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
const [loadLineTicketData, { loading, data: lineTicketData }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
|
||||
const [loadLineTicketData, { loading, data: lineTicketData, refetch }] = useLazyQuery(GET_LINE_TICKET_BY_PK, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
@@ -91,21 +92,22 @@ export function TimeTicketModalComponent({
|
||||
value={value === "timetickets.labels.shift" ? t(value) : value}
|
||||
{...props}
|
||||
disabled={value === "timetickets.labels.shift" || disabled}
|
||||
>
|
||||
{emps &&
|
||||
emps.rates.map((item) => (
|
||||
<Select.Option key={item.cost_center} value={item.cost_center}>
|
||||
{item.cost_center === "timetickets.labels.shift"
|
||||
options={
|
||||
emps &&
|
||||
emps.rates.map((item) => ({
|
||||
value: item.cost_center,
|
||||
label:
|
||||
item.cost_center === "timetickets.labels.shift"
|
||||
? t(item.cost_center)
|
||||
: bodyshop.cdk_dealerid ||
|
||||
bodyshop.pbs_serialnumber ||
|
||||
bodyshop.rr_dealerid ||
|
||||
Enhanced_Payroll.treatment === "on"
|
||||
? t(`joblines.fields.lbr_types.${item.cost_center.toUpperCase()}`)
|
||||
: item.cost_center}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
: item.cost_center
|
||||
}))
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const MemoInput = ({ value, ...props }) => (
|
||||
@@ -320,13 +322,34 @@ export function TimeTicketModalComponent({
|
||||
</Form.Item>
|
||||
</LayoutFormRow>
|
||||
|
||||
<LaborAllocationContainer jobid={watchedJobId || null} loading={loading} lineTicketData={lineTicketData} />
|
||||
<LaborAllocationContainer
|
||||
jobid={watchedJobId || null}
|
||||
loading={loading}
|
||||
lineTicketData={lineTicketData}
|
||||
bodyshop={bodyshop}
|
||||
refetch={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideTimeTickets = false }) {
|
||||
export function LaborAllocationContainer({
|
||||
jobid,
|
||||
loading,
|
||||
lineTicketData,
|
||||
hideTimeTickets = false,
|
||||
bodyshop,
|
||||
refetch
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
treatments: { Enhanced_Payroll }
|
||||
} = useTreatmentsWithConfig({
|
||||
attributes: {},
|
||||
names: ["Enhanced_Payroll"],
|
||||
splitKey: bodyshop.imexshopid
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSkeleton />;
|
||||
if (!lineTicketData) return null;
|
||||
if (!jobid) return null;
|
||||
@@ -337,12 +360,23 @@ export function LaborAllocationContainer({ jobid, loading, lineTicketData, hideT
|
||||
<JobEmployeeAssignmentsContainer job={lineTicketData.jobs_by_pk} />
|
||||
</Card>
|
||||
|
||||
<LaborAllocationsTable
|
||||
jobId={jobid}
|
||||
joblines={lineTicketData.joblines}
|
||||
timetickets={lineTicketData.timetickets}
|
||||
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
|
||||
/>
|
||||
{Enhanced_Payroll.treatment === "on" ? (
|
||||
<PayrollLaborAllocationsTable
|
||||
jobId={jobid}
|
||||
joblines={lineTicketData.joblines}
|
||||
timetickets={lineTicketData.timetickets}
|
||||
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
|
||||
refetch={refetch}
|
||||
bodyshop={bodyshop}
|
||||
/>
|
||||
) : (
|
||||
<LaborAllocationsTable
|
||||
jobId={jobid}
|
||||
joblines={lineTicketData.joblines}
|
||||
timetickets={lineTicketData.timetickets}
|
||||
adjustments={lineTicketData.jobs_by_pk.lbr_adjustments}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!hideTimeTickets && (
|
||||
<TimeTicketList loading={loading} timetickets={jobid ? lineTicketData.timetickets : []} techConsole />
|
||||
|
||||
@@ -20,13 +20,15 @@ export function TimeTicketShiftFormComponent() {
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="timetickets.labels.amshift">{t("timetickets.labels.amshift")}</Select.Option>
|
||||
<Select.Option value="timetickets.labels.ambreak">{t("timetickets.labels.ambreak")}</Select.Option>
|
||||
<Select.Option value="timetickets.labels.lunch">{t("timetickets.labels.lunch")}</Select.Option>
|
||||
<Select.Option value="timetickets.labels.pmshift">{t("timetickets.labels.pmshift")}</Select.Option>
|
||||
<Select.Option value="timetickets.labels.pmbreak">{t("timetickets.labels.pmbreak")}</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "timetickets.labels.amshift", label: t("timetickets.labels.amshift") },
|
||||
{ value: "timetickets.labels.ambreak", label: t("timetickets.labels.ambreak") },
|
||||
{ value: "timetickets.labels.lunch", label: t("timetickets.labels.lunch") },
|
||||
{ value: "timetickets.labels.pmshift", label: t("timetickets.labels.pmshift") },
|
||||
{ value: "timetickets.labels.pmbreak", label: t("timetickets.labels.pmbreak") }
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
} from "../../graphql/vehicles.queries";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
const [callSearch, { loading, error, data }] = useLazyQuery(SEARCH_VEHICLES_FOR_AUTOCOMPLETE);
|
||||
|
||||
@@ -73,15 +71,12 @@ const VehicleSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
|
||||
onSelect={handleSelect}
|
||||
notFoundContent={loading ? <LoadingOutlined /> : <Empty />}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{theOptions
|
||||
? theOptions.map((o) => (
|
||||
<Option key={o.id} value={o.id}>
|
||||
{`${o.v_vin || ""} ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""} `}
|
||||
</Option>
|
||||
))
|
||||
: null}
|
||||
</Select>
|
||||
options={theOptions?.map((o) => ({
|
||||
key: o.id,
|
||||
value: o.id,
|
||||
label: `${o.v_vin || ""} ${o.v_model_yr || ""} ${o.v_make_desc || ""} ${o.v_model_desc || ""} `
|
||||
}))}
|
||||
/>
|
||||
{idLoading || loading ? <LoadingOutlined /> : null}
|
||||
{error ? <AlertComponent title={error.message} type="error" /> : null}
|
||||
{idError ? <AlertComponent title={idError.message} type="error" /> : null}
|
||||
|
||||
@@ -3,28 +3,81 @@ import { Select, Space, Tag } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
// To be used as a form element only.
|
||||
|
||||
const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, preferredMake, showPhone, ref }) => {
|
||||
const [option, setOption] = useState(value);
|
||||
|
||||
// Sync internal state when value prop changes (e.g., from form.setFieldsValue)
|
||||
useEffect(() => {
|
||||
if (value !== option && onChange) {
|
||||
onChange(option);
|
||||
if (value !== option) {
|
||||
setOption(value);
|
||||
}
|
||||
}, [value, option, onChange]);
|
||||
}, [value, option]);
|
||||
|
||||
const handleChange = (newValue) => {
|
||||
setOption(newValue);
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const favorites =
|
||||
preferredMake && options
|
||||
? options.filter((o) => o.favorite.filter((f) => f.toLowerCase() === preferredMake.toLowerCase()).length > 0)
|
||||
: [];
|
||||
|
||||
const formatOption = (o, isFavorite = false) => ({
|
||||
key: isFavorite ? `favorite-${o.id}` : o.id,
|
||||
value: o.id,
|
||||
name: o.name,
|
||||
discount: o.discount,
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{o.name}
|
||||
</div>
|
||||
<Space style={{ marginLeft: "1rem" }}>
|
||||
{isFavorite && <HeartOutlined style={{ color: "red" }} />}
|
||||
{!isFavorite &&
|
||||
o.tags?.map((tag, idx) => (
|
||||
<Tag key={idx} style={{ marginLeft: "0.5rem" }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
|
||||
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
const allOptions = [
|
||||
...(favorites?.map((o) => formatOption(o, true)) || []),
|
||||
...(options?.map((o) => formatOption(o, false)) || [])
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
ref={ref}
|
||||
showSearch
|
||||
showSearch={{
|
||||
optionFilterProp: "name"
|
||||
}}
|
||||
value={option}
|
||||
style={{
|
||||
width: "100%"
|
||||
@@ -58,77 +111,12 @@ const VendorSearchSelect = ({ value, onChange, options, onSelect, disabled, pref
|
||||
);
|
||||
}}
|
||||
popupMatchSelectWidth={false}
|
||||
onChange={setOption}
|
||||
optionFilterProp="name"
|
||||
onChange={handleChange}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled || false}
|
||||
optionLabelProp="name"
|
||||
>
|
||||
{favorites &&
|
||||
favorites.map((o) => (
|
||||
<Option key={`favorite-${o.id}`} value={o.id} name={o.name} discount={o.discount}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{o.name}
|
||||
</div>
|
||||
<Space style={{ marginLeft: "1rem" }}>
|
||||
<HeartOutlined style={{ color: "red" }} />
|
||||
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
|
||||
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
{options &&
|
||||
options.map((o) => (
|
||||
<Option key={o.id} value={o.id} name={o.name} discount={o.discount}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexWrap: "nowrap",
|
||||
width: "100%"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap"
|
||||
}}
|
||||
>
|
||||
{o.name}
|
||||
</div>
|
||||
<Space style={{ marginLeft: "1rem" }}>
|
||||
{o.tags?.map((tag, idx) => (
|
||||
<Tag key={idx} style={{ marginLeft: "0.5rem" }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{o.phone && showPhone && <PhoneNumberFormatter>{o.phone}</PhoneNumberFormatter>}
|
||||
{o.discount && o.discount !== 0 ? <Tag color="green">{`${o.discount * 100}%`}</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
options={allOptions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default VendorSearchSelect;
|
||||
|
||||
@@ -185,6 +185,7 @@ export const QUERY_BILL_BY_PK = gql`
|
||||
id
|
||||
}
|
||||
jobline {
|
||||
alt_partno
|
||||
oem_partno
|
||||
part_type
|
||||
}
|
||||
|
||||
@@ -119,12 +119,13 @@ export function DmsContainer({ setBreadcrumbs, setSelectedHeader }) {
|
||||
setLogLevel(value);
|
||||
socket.emit("set-log-level", value);
|
||||
}}
|
||||
>
|
||||
<Select.Option key="DEBUG">DEBUG</Select.Option>
|
||||
<Select.Option key="INFO">INFO</Select.Option>
|
||||
<Select.Option key="WARN">WARN</Select.Option>
|
||||
<Select.Option key="ERROR">ERROR</Select.Option>
|
||||
</Select>
|
||||
options={[
|
||||
{ key: "DEBUG", value: "DEBUG", label: "DEBUG" },
|
||||
{ key: "INFO", value: "INFO", label: "INFO" },
|
||||
{ key: "WARN", value: "WARN", label: "WARN" },
|
||||
{ key: "ERROR", value: "ERROR", label: "ERROR" }
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -541,13 +541,14 @@ export function DmsContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, inse
|
||||
setLogLevel(value);
|
||||
setActiveLogLevel(value);
|
||||
}}
|
||||
>
|
||||
<Select.Option key="SILLY">SILLY</Select.Option>
|
||||
<Select.Option key="DEBUG">DEBUG</Select.Option>
|
||||
<Select.Option key="INFO">INFO</Select.Option>
|
||||
<Select.Option key="WARN">WARN</Select.Option>
|
||||
<Select.Option key="ERROR">ERROR</Select.Option>
|
||||
</Select>
|
||||
options={[
|
||||
{ key: "SILLY", value: "SILLY", label: "SILLY" },
|
||||
{ key: "DEBUG", value: "DEBUG", label: "DEBUG" },
|
||||
{ key: "INFO", value: "INFO", label: "INFO" },
|
||||
{ key: "WARN", value: "WARN", label: "WARN" },
|
||||
{ key: "ERROR", value: "ERROR", label: "ERROR" }
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setLogs([])}>Clear Logs</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -440,13 +440,15 @@ export function JobsCloseComponent({ job, bodyshop, jobRO, insertAuditTrail, set
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Select style={{ minWidth: "12rem" }} disabled={jobRO}>
|
||||
{bodyshop.md_ins_cos.map((s) => (
|
||||
<Select.Option key={s.name} value={s.name}>
|
||||
{s.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
style={{ minWidth: "12rem" }}
|
||||
disabled={jobRO}
|
||||
options={bodyshop.md_ins_cos.map((s) => ({
|
||||
key: s.name,
|
||||
value: s.name,
|
||||
label: s.name
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
||||
@@ -237,7 +237,13 @@ export function JobsDetailPage({
|
||||
"rate_mapa",
|
||||
"rate_mahw",
|
||||
"rate_mash",
|
||||
"rate_matd"
|
||||
"rate_matd",
|
||||
"flat_rate_ats",
|
||||
"state_tax_rate",
|
||||
"tax_lbr_rt",
|
||||
"tax_shop_mat_rt",
|
||||
"tax_paint_mat_rt",
|
||||
"tax_sub_rt"
|
||||
],
|
||||
(meta) => meta && meta.touched
|
||||
);
|
||||
|
||||
@@ -161,12 +161,14 @@
|
||||
"fields": {
|
||||
"actual_cost": "Actual Cost",
|
||||
"actual_price": "Retail",
|
||||
"confidence": "Confidence",
|
||||
"cost_center": "Cost Center",
|
||||
"federal_tax_applicable": "Fed. Tax?",
|
||||
"jobline": "Job Line",
|
||||
"line_desc": "Line Description",
|
||||
"local_tax_applicable": "Loc. Tax?",
|
||||
"location": "Location",
|
||||
"oem_partno": "Part #",
|
||||
"quantity": "Quantity",
|
||||
"state_tax_applicable": "St. Tax?"
|
||||
},
|
||||
@@ -191,6 +193,8 @@
|
||||
"return": "Return Items"
|
||||
},
|
||||
"errors": {
|
||||
"calculating_totals": "Error Calculating Totals",
|
||||
"calculating_totals_generic": "Please ensure all fields are properly completed. ",
|
||||
"creating": "Error adding bill. {{error}}",
|
||||
"deleting": "Error deleting bill. {{error}}",
|
||||
"existinginventoryline": "This bill cannot be deleted as it is tied to items in inventory.",
|
||||
@@ -217,6 +221,24 @@
|
||||
},
|
||||
"labels": {
|
||||
"actions": "Actions",
|
||||
"ai": {
|
||||
"accept_and_continue": "Accept and Continue",
|
||||
"confidence": {
|
||||
"breakdown": "Confidence Breakdown",
|
||||
"match": "Jobline Match",
|
||||
"missing_data": "Missing Data",
|
||||
"ocr": "Optical Character Recognition",
|
||||
"overall": "Overall"
|
||||
},
|
||||
"disclaimer_title": "AI Scan Beta Disclaimer",
|
||||
"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"
|
||||
},
|
||||
"bill_lines": "Bill Lines",
|
||||
"bill_total": "Bill Total Amount",
|
||||
"billcmtotal": "Credit Memos",
|
||||
@@ -1291,10 +1313,11 @@
|
||||
"vehicle": "Vehicle"
|
||||
},
|
||||
"labels": {
|
||||
"apply": "Apply",
|
||||
"actions": "Actions",
|
||||
"apply": "Apply",
|
||||
"areyousure": "Are you sure?",
|
||||
"barcode": "Barcode",
|
||||
"beta": "BETA",
|
||||
"cancel": "Are you sure you want to cancel? Your changes will not be saved.",
|
||||
"changelog": "Change Log",
|
||||
"clear": "Clear",
|
||||
@@ -1367,6 +1390,7 @@
|
||||
"unknown": "Unknown",
|
||||
"unsavedchanges": "Unsaved changes.",
|
||||
"username": "Username",
|
||||
"validationerror": "Please fix the following errors:",
|
||||
"view": "View",
|
||||
"wednesday": "Wednesday",
|
||||
"yes": "Yes"
|
||||
@@ -1822,14 +1846,14 @@
|
||||
"name": "Payer Name",
|
||||
"payer_type": "Payer"
|
||||
},
|
||||
"rr_opcode": "RR OpCode",
|
||||
"rr_opcode_base": "Base",
|
||||
"rr_opcode_prefix": "Prefix",
|
||||
"rr_opcode_suffix": "Suffix",
|
||||
"sale": "Sale",
|
||||
"sale_dms_acctnumber": "Sale DMS Acct #",
|
||||
"story": "Story",
|
||||
"vinowner": "VIN Owner",
|
||||
"rr_opcode": "RR OpCode",
|
||||
"rr_opcode_prefix": "Prefix",
|
||||
"rr_opcode_suffix": "Suffix",
|
||||
"rr_opcode_base": "Base"
|
||||
"vinowner": "VIN Owner"
|
||||
},
|
||||
"dms_allocation": "DMS Allocation",
|
||||
"driveable": "Driveable",
|
||||
|
||||
@@ -161,12 +161,14 @@
|
||||
"fields": {
|
||||
"actual_cost": "",
|
||||
"actual_price": "",
|
||||
"confidence": "",
|
||||
"cost_center": "",
|
||||
"federal_tax_applicable": "",
|
||||
"jobline": "",
|
||||
"line_desc": "",
|
||||
"local_tax_applicable": "",
|
||||
"location": "",
|
||||
"oem_partno": "",
|
||||
"quantity": "",
|
||||
"state_tax_applicable": ""
|
||||
},
|
||||
@@ -191,6 +193,8 @@
|
||||
"return": ""
|
||||
},
|
||||
"errors": {
|
||||
"calculating_totals": "",
|
||||
"calculating_totals_generic": "",
|
||||
"creating": "",
|
||||
"deleting": "",
|
||||
"existinginventoryline": "",
|
||||
@@ -217,6 +221,24 @@
|
||||
},
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"ai": {
|
||||
"accept_and_continue": "",
|
||||
"confidence": {
|
||||
"breakdown": "",
|
||||
"match": "",
|
||||
"missing_data": "",
|
||||
"ocr": "",
|
||||
"overall": ""
|
||||
},
|
||||
"disclaimer_title": "",
|
||||
"generic_failure": "",
|
||||
"multipage": "",
|
||||
"processing": "",
|
||||
"scan": "",
|
||||
"scancomplete": "",
|
||||
"scanfailed": "",
|
||||
"scanstarted": ""
|
||||
},
|
||||
"bill_lines": "",
|
||||
"bill_total": "",
|
||||
"billcmtotal": "",
|
||||
@@ -1291,10 +1313,11 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"apply": "",
|
||||
"actions": "Comportamiento",
|
||||
"apply": "",
|
||||
"areyousure": "",
|
||||
"barcode": "código de barras",
|
||||
"beta": "",
|
||||
"cancel": "",
|
||||
"changelog": "",
|
||||
"clear": "",
|
||||
@@ -1367,6 +1390,7 @@
|
||||
"unknown": "Desconocido",
|
||||
"unsavedchanges": "",
|
||||
"username": "",
|
||||
"validationerror": "",
|
||||
"view": "",
|
||||
"wednesday": "",
|
||||
"yes": ""
|
||||
@@ -1822,14 +1846,14 @@
|
||||
"name": "",
|
||||
"payer_type": ""
|
||||
},
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_base": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"sale": "",
|
||||
"sale_dms_acctnumber": "",
|
||||
"story": "",
|
||||
"vinowner": "",
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"rr_opcode_base": ""
|
||||
"vinowner": ""
|
||||
},
|
||||
"dms_allocation": "",
|
||||
"driveable": "",
|
||||
|
||||
@@ -161,12 +161,14 @@
|
||||
"fields": {
|
||||
"actual_cost": "",
|
||||
"actual_price": "",
|
||||
"confidence": "",
|
||||
"cost_center": "",
|
||||
"federal_tax_applicable": "",
|
||||
"jobline": "",
|
||||
"line_desc": "",
|
||||
"local_tax_applicable": "",
|
||||
"location": "",
|
||||
"oem_partno": "",
|
||||
"quantity": "",
|
||||
"state_tax_applicable": ""
|
||||
},
|
||||
@@ -191,6 +193,8 @@
|
||||
"return": ""
|
||||
},
|
||||
"errors": {
|
||||
"calculating_totals": "",
|
||||
"calculating_totals_generic": "",
|
||||
"creating": "",
|
||||
"deleting": "",
|
||||
"existinginventoryline": "",
|
||||
@@ -217,6 +221,24 @@
|
||||
},
|
||||
"labels": {
|
||||
"actions": "",
|
||||
"ai": {
|
||||
"accept_and_continue": "",
|
||||
"confidence": {
|
||||
"breakdown": "",
|
||||
"match": "",
|
||||
"missing_data": "",
|
||||
"ocr": "",
|
||||
"overall": ""
|
||||
},
|
||||
"disclaimer_title": "",
|
||||
"generic_failure": "",
|
||||
"multipage": "",
|
||||
"processing": "",
|
||||
"scan": "",
|
||||
"scancomplete": "",
|
||||
"scanfailed": "",
|
||||
"scanstarted": ""
|
||||
},
|
||||
"bill_lines": "",
|
||||
"bill_total": "",
|
||||
"billcmtotal": "",
|
||||
@@ -1291,10 +1313,11 @@
|
||||
"vehicle": ""
|
||||
},
|
||||
"labels": {
|
||||
"apply": "",
|
||||
"actions": "actes",
|
||||
"apply": "",
|
||||
"areyousure": "",
|
||||
"barcode": "code à barre",
|
||||
"beta": "",
|
||||
"cancel": "",
|
||||
"changelog": "",
|
||||
"clear": "",
|
||||
@@ -1367,6 +1390,7 @@
|
||||
"unknown": "Inconnu",
|
||||
"unsavedchanges": "",
|
||||
"username": "",
|
||||
"validationerror": "",
|
||||
"view": "",
|
||||
"wednesday": "",
|
||||
"yes": ""
|
||||
@@ -1822,14 +1846,14 @@
|
||||
"name": "",
|
||||
"payer_type": ""
|
||||
},
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_base": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"sale": "",
|
||||
"sale_dms_acctnumber": "",
|
||||
"story": "",
|
||||
"vinowner": "",
|
||||
"rr_opcode": "",
|
||||
"rr_opcode_prefix": "",
|
||||
"rr_opcode_suffix": "",
|
||||
"rr_opcode_base": ""
|
||||
"vinowner": ""
|
||||
},
|
||||
"dms_allocation": "",
|
||||
"driveable": "",
|
||||
|
||||
@@ -1,44 +1,43 @@
|
||||
import { Select } from "antd";
|
||||
import i18n from "../translations/i18n";
|
||||
|
||||
export default function CiecaSelect(parts = true, labor = true) {
|
||||
return (
|
||||
<>
|
||||
{labor && (
|
||||
<>
|
||||
<Select.Option value="LAA">{i18n.t("joblines.fields.lbr_types.LAA")}</Select.Option>
|
||||
<Select.Option value="LAB">{i18n.t("joblines.fields.lbr_types.LAB")}</Select.Option>
|
||||
<Select.Option value="LAD">{i18n.t("joblines.fields.lbr_types.LAD")}</Select.Option>
|
||||
<Select.Option value="LAE">{i18n.t("joblines.fields.lbr_types.LAE")}</Select.Option>
|
||||
<Select.Option value="LAF">{i18n.t("joblines.fields.lbr_types.LAF")}</Select.Option>
|
||||
<Select.Option value="LAG">{i18n.t("joblines.fields.lbr_types.LAG")}</Select.Option>
|
||||
<Select.Option value="LAM">{i18n.t("joblines.fields.lbr_types.LAM")}</Select.Option>
|
||||
<Select.Option value="LAR">{i18n.t("joblines.fields.lbr_types.LAR")}</Select.Option>
|
||||
<Select.Option value="LAS">{i18n.t("joblines.fields.lbr_types.LAS")}</Select.Option>
|
||||
<Select.Option value="LAU">{i18n.t("joblines.fields.lbr_types.LAU")}</Select.Option>
|
||||
<Select.Option value="LA1">{i18n.t("joblines.fields.lbr_types.LA1")}</Select.Option>
|
||||
<Select.Option value="LA2">{i18n.t("joblines.fields.lbr_types.LA2")}</Select.Option>
|
||||
<Select.Option value="LA3">{i18n.t("joblines.fields.lbr_types.LA3")}</Select.Option>
|
||||
<Select.Option value="LA4">{i18n.t("joblines.fields.lbr_types.LA4")}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
{parts && (
|
||||
<>
|
||||
<Select.Option value="PAA">{i18n.t("joblines.fields.part_types.PAA")}</Select.Option>
|
||||
<Select.Option value="PAC">{i18n.t("joblines.fields.part_types.PAC")}</Select.Option>
|
||||
|
||||
<Select.Option value="PAL">{i18n.t("joblines.fields.part_types.PAL")}</Select.Option>
|
||||
<Select.Option value="PAG">{i18n.t("joblines.fields.part_types.PAG")}</Select.Option>
|
||||
<Select.Option value="PAM">{i18n.t("joblines.fields.part_types.PAM")}</Select.Option>
|
||||
<Select.Option value="PAP">{i18n.t("joblines.fields.part_types.PAP")}</Select.Option>
|
||||
<Select.Option value="PAN">{i18n.t("joblines.fields.part_types.PAN")}</Select.Option>
|
||||
<Select.Option value="PAO">{i18n.t("joblines.fields.part_types.PAO")}</Select.Option>
|
||||
<Select.Option value="PAR">{i18n.t("joblines.fields.part_types.PAR")}</Select.Option>
|
||||
<Select.Option value="PAS">{i18n.t("joblines.fields.part_types.PAS")}</Select.Option>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const options = [];
|
||||
|
||||
if (labor) {
|
||||
options.push(
|
||||
{ value: "LAA", label: i18n.t("joblines.fields.lbr_types.LAA") },
|
||||
{ value: "LAB", label: i18n.t("joblines.fields.lbr_types.LAB") },
|
||||
{ value: "LAD", label: i18n.t("joblines.fields.lbr_types.LAD") },
|
||||
{ value: "LAE", label: i18n.t("joblines.fields.lbr_types.LAE") },
|
||||
{ value: "LAF", label: i18n.t("joblines.fields.lbr_types.LAF") },
|
||||
{ value: "LAG", label: i18n.t("joblines.fields.lbr_types.LAG") },
|
||||
{ value: "LAM", label: i18n.t("joblines.fields.lbr_types.LAM") },
|
||||
{ value: "LAR", label: i18n.t("joblines.fields.lbr_types.LAR") },
|
||||
{ value: "LAS", label: i18n.t("joblines.fields.lbr_types.LAS") },
|
||||
{ value: "LAU", label: i18n.t("joblines.fields.lbr_types.LAU") },
|
||||
{ value: "LA1", label: i18n.t("joblines.fields.lbr_types.LA1") },
|
||||
{ value: "LA2", label: i18n.t("joblines.fields.lbr_types.LA2") },
|
||||
{ value: "LA3", label: i18n.t("joblines.fields.lbr_types.LA3") },
|
||||
{ value: "LA4", label: i18n.t("joblines.fields.lbr_types.LA4") }
|
||||
);
|
||||
}
|
||||
|
||||
if (parts) {
|
||||
options.push(
|
||||
{ value: "PAA", label: i18n.t("joblines.fields.part_types.PAA") },
|
||||
{ value: "PAC", label: i18n.t("joblines.fields.part_types.PAC") },
|
||||
{ value: "PAL", label: i18n.t("joblines.fields.part_types.PAL") },
|
||||
{ value: "PAG", label: i18n.t("joblines.fields.part_types.PAG") },
|
||||
{ value: "PAM", label: i18n.t("joblines.fields.part_types.PAM") },
|
||||
{ value: "PAP", label: i18n.t("joblines.fields.part_types.PAP") },
|
||||
{ value: "PAN", label: i18n.t("joblines.fields.part_types.PAN") },
|
||||
{ value: "PAO", label: i18n.t("joblines.fields.part_types.PAO") },
|
||||
{ value: "PAR", label: i18n.t("joblines.fields.part_types.PAR") },
|
||||
{ value: "PAS", label: i18n.t("joblines.fields.part_types.PAS") }
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function GetPartTypeName(part_type) {
|
||||
|
||||
1602
package-lock.json
generated
1602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
42
package.json
42
package.json
@@ -18,23 +18,25 @@
|
||||
"job-totals-fixtures:local": "docker exec node-app /usr/bin/node /app/download-job-totals-fixtures.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.978.0",
|
||||
"@aws-sdk/client-elasticache": "^3.978.0",
|
||||
"@aws-sdk/client-s3": "^3.978.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.978.0",
|
||||
"@aws-sdk/client-ses": "^3.978.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.3",
|
||||
"@aws-sdk/lib-storage": "^3.978.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.978.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "^3.992.0",
|
||||
"@aws-sdk/client-elasticache": "^3.992.0",
|
||||
"@aws-sdk/client-s3": "^3.992.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.992.0",
|
||||
"@aws-sdk/client-ses": "^3.992.0",
|
||||
"@aws-sdk/client-sqs": "^3.975.0",
|
||||
"@aws-sdk/client-textract": "^3.975.0",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.9",
|
||||
"@aws-sdk/lib-storage": "^3.992.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.992.0",
|
||||
"@opensearch-project/opensearch": "^2.13.0",
|
||||
"@socket.io/admin-ui": "^0.5.1",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4": "^1.13.2",
|
||||
"axios": "^1.13.4",
|
||||
"axios": "^1.13.5",
|
||||
"axios-curlirize": "^2.0.0",
|
||||
"better-queue": "^3.8.12",
|
||||
"bullmq": "^5.67.2",
|
||||
"bullmq": "^5.69.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"cloudinary": "^2.9.0",
|
||||
"compression": "^1.8.1",
|
||||
@@ -42,17 +44,18 @@
|
||||
"cors": "^2.8.6",
|
||||
"crisp-status-reporter": "^1.2.2",
|
||||
"dinero.js": "^1.9.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.1",
|
||||
"fast-xml-parser": "^5.3.4",
|
||||
"firebase-admin": "^13.6.0",
|
||||
"fast-xml-parser": "^5.3.6",
|
||||
"firebase-admin": "^13.6.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"graphql": "^16.12.0",
|
||||
"graphql-request": "^6.1.0",
|
||||
"intuit-oauth": "^4.2.2",
|
||||
"ioredis": "^5.9.2",
|
||||
"ioredis": "^5.9.3",
|
||||
"json-2-csv": "^5.5.10",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"juice": "^11.1.0",
|
||||
"juice": "^11.1.1",
|
||||
"lodash": "^4.17.23",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
@@ -60,16 +63,17 @@
|
||||
"mustache": "^4.2.0",
|
||||
"node-persist": "^4.0.4",
|
||||
"nodemailer": "^6.10.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"phone": "^3.1.70",
|
||||
"query-string": "7.1.3",
|
||||
"recursive-diff": "^1.0.9",
|
||||
"rimraf": "^6.1.2",
|
||||
"rimraf": "^6.1.3",
|
||||
"skia-canvas": "^3.0.8",
|
||||
"soap": "^1.6.4",
|
||||
"soap": "^1.7.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-adapter": "^2.5.6",
|
||||
"ssh2-sftp-client": "^11.0.0",
|
||||
"twilio": "^5.12.0",
|
||||
"twilio": "^5.12.2",
|
||||
"uuid": "^11.1.0",
|
||||
"winston": "^3.19.0",
|
||||
"winston-cloudwatch": "^6.3.0",
|
||||
@@ -82,7 +86,7 @@
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^17.2.0",
|
||||
"globals": "^17.3.0",
|
||||
"mock-require": "^3.0.3",
|
||||
"p-limit": "^3.1.0",
|
||||
"prettier": "^3.8.1",
|
||||
|
||||
@@ -127,6 +127,8 @@ const applyRoutes = ({ app }) => {
|
||||
app.use("/payroll", require("./server/routes/payrollRoutes"));
|
||||
app.use("/sso", require("./server/routes/ssoRoutes"));
|
||||
app.use("/integrations", require("./server/routes/intergrationRoutes"));
|
||||
app.use("/ai", require("./server/routes/aiRoutes"));
|
||||
|
||||
app.use("/chatter", require("./server/routes/chatterRoutes"));
|
||||
|
||||
// Default route for forbidden access
|
||||
@@ -425,6 +427,12 @@ const loadQueues = async ({ pubClient, logger, redisHelpers, ioRedis }) => {
|
||||
chatterApiQueue.on("error", (error) => {
|
||||
logger.log(`Error in chatterApiQueue: ${error}`, "ERROR", "queue", "api", null, { error: error?.message });
|
||||
});
|
||||
|
||||
// Initialize bill-ocr with Redis client
|
||||
const { initializeBillOcr, startSQSPolling } = require("./server/ai/bill-ocr/bill-ocr");
|
||||
initializeBillOcr(pubClient);
|
||||
// Start SQS polling for Textract notifications
|
||||
startSQSPolling();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -454,6 +462,7 @@ const main = async () => {
|
||||
try {
|
||||
await server.listen(port);
|
||||
logger.log(`Server started on port ${port}`, "INFO", "api");
|
||||
|
||||
} catch (error) {
|
||||
logger.log(`Server failed to start on port ${port}`, "ERROR", "api", null, { error: error.message });
|
||||
}
|
||||
|
||||
718
server/ai/bill-ocr/bill-ocr-generator.js
Normal file
718
server/ai/bill-ocr/bill-ocr-generator.js
Normal file
@@ -0,0 +1,718 @@
|
||||
|
||||
|
||||
const Fuse = require('fuse.js');
|
||||
const { has } = require("lodash");
|
||||
const { standardizedFieldsnames } = require('./bill-ocr-normalize');
|
||||
const InstanceManager = require("../../utils/instanceMgr").default;
|
||||
|
||||
const PRICE_PERCENT_MARGIN_TOLERANCE = 0.5; //Used to make sure prices and costs are likely.
|
||||
|
||||
// Helper function to normalize fields
|
||||
const normalizePartNumber = (str) => {
|
||||
return str.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
};
|
||||
|
||||
const normalizeText = (str) => {
|
||||
return str.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, ' ').trim().toUpperCase();
|
||||
};
|
||||
const normalizePrice = (str) => {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str.replace(/[^0-9.-]+/g, "");
|
||||
};
|
||||
|
||||
//More complex function. Not necessary at the moment, keeping for reference.
|
||||
// const normalizePriceFinal = (str) => {
|
||||
// if (typeof str !== 'string') {
|
||||
// // If it's already a number, format to 2 decimals
|
||||
// const num = parseFloat(str);
|
||||
// return isNaN(num) ? 0 : num;
|
||||
// }
|
||||
|
||||
// // First, try to extract valid decimal number patterns (e.g., "123.45")
|
||||
// const decimalPattern = /\d+\.\d{1,2}/g;
|
||||
// const decimalMatches = str.match(decimalPattern);
|
||||
|
||||
// if (decimalMatches && decimalMatches.length > 0) {
|
||||
// // Found valid decimal number(s)
|
||||
// const numbers = decimalMatches.map(m => parseFloat(m)).filter(n => !isNaN(n) && n > 0);
|
||||
|
||||
// if (numbers.length === 1) {
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// if (numbers.length > 1) {
|
||||
// // Check if all numbers are the same (e.g., "47.57.47.57" -> [47.57, 47.57])
|
||||
// const uniqueNumbers = [...new Set(numbers)];
|
||||
// if (uniqueNumbers.length === 1) {
|
||||
// return uniqueNumbers[0];
|
||||
// }
|
||||
|
||||
// // Check if numbers are very close (within 1% tolerance)
|
||||
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
|
||||
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
|
||||
|
||||
// if (allClose) {
|
||||
// return avg;
|
||||
// }
|
||||
|
||||
// // Return the first number (most likely correct)
|
||||
// return numbers[0];
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Fallback: Split on common delimiters and extract all potential numbers
|
||||
// const parts = str.split(/[\/|\\,;]/).map(part => part.trim()).filter(part => part.length > 0);
|
||||
|
||||
// if (parts.length > 1) {
|
||||
// // Multiple values detected - extract and parse all valid numbers
|
||||
// const numbers = parts
|
||||
// .map(part => {
|
||||
// const cleaned = part.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? null : parsed;
|
||||
// })
|
||||
// .filter(num => num !== null && num > 0);
|
||||
|
||||
// if (numbers.length === 0) {
|
||||
// // No valid numbers found, try fallback to basic cleaning
|
||||
// const cleaned = str.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? 0 : parsed;
|
||||
// }
|
||||
|
||||
// if (numbers.length === 1) {
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// // Multiple valid numbers
|
||||
// const uniqueNumbers = [...new Set(numbers)];
|
||||
|
||||
// if (uniqueNumbers.length === 1) {
|
||||
// return uniqueNumbers[0];
|
||||
// }
|
||||
|
||||
// // Check if numbers are very close (within 1% tolerance)
|
||||
// const avg = numbers.reduce((a, b) => a + b, 0) / numbers.length;
|
||||
// const allClose = numbers.every(num => Math.abs(num - avg) / avg < 0.01);
|
||||
|
||||
// if (allClose) {
|
||||
// return avg;
|
||||
// }
|
||||
|
||||
// // Return the first valid number
|
||||
// return numbers[0];
|
||||
// }
|
||||
|
||||
// // Single value or no delimiters, clean normally
|
||||
// const cleaned = str.replace(/[^0-9.-]+/g, "");
|
||||
// const parsed = parseFloat(cleaned);
|
||||
// return isNaN(parsed) ? 0 : parsed;
|
||||
// };
|
||||
|
||||
|
||||
|
||||
// Helper function to calculate Textract OCR confidence (0-100%)
|
||||
const calculateTextractConfidence = (textractLineItem) => {
|
||||
if (!textractLineItem || Object.keys(textractLineItem).length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const confidenceValues = [];
|
||||
|
||||
// Collect confidence from all fields in the line item
|
||||
Object.values(textractLineItem).forEach(field => {
|
||||
if (field.confidence && typeof field.confidence === 'number') {
|
||||
confidenceValues.push(field.confidence);
|
||||
}
|
||||
});
|
||||
|
||||
if (confidenceValues.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Check if critical normalized labels are present
|
||||
const hasActualCost = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_cost);
|
||||
const hasActualPrice = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.actual_price);
|
||||
const hasLineDesc = Object.values(textractLineItem).some(field => field.normalizedLabel === standardizedFieldsnames.line_desc);
|
||||
|
||||
// Calculate weighted average, giving more weight to important fields
|
||||
// If we can identify key fields (ITEM, PRODUCT_CODE, PRICE), weight them higher
|
||||
let totalWeight = 0;
|
||||
let weightedSum = 0;
|
||||
|
||||
Object.entries(textractLineItem).forEach(([key, field]) => {
|
||||
if (field.confidence && typeof field.confidence === 'number') {
|
||||
// Weight important fields higher
|
||||
let weight = 1;
|
||||
if (field.normalizedLabel === standardizedFieldsnames.actual_cost || field.normalizedLabel === standardizedFieldsnames.actual_price) {
|
||||
weight = 4;
|
||||
}
|
||||
else if (field.normalizedLabel === standardizedFieldsnames.part_no || field.normalizedLabel === standardizedFieldsnames.line_desc) {
|
||||
weight = 3.5;
|
||||
}
|
||||
else if (field.normalizedLabel === standardizedFieldsnames.quantity) {
|
||||
weight = 3.5;
|
||||
}
|
||||
// We generally ignore the key from textract. Keeping for future reference.
|
||||
// else if (key === 'ITEM' || key === 'PRODUCT_CODE') {
|
||||
// weight = 3; // Description and part number are most important
|
||||
// } else if (key === 'PRICE' || key === 'UNIT_PRICE' || key === 'QUANTITY') {
|
||||
// weight = 2; // Price and quantity moderately important
|
||||
// }
|
||||
|
||||
weightedSum += field.confidence * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
});
|
||||
|
||||
let avgConfidence = totalWeight > 0 ? weightedSum / totalWeight : 0;
|
||||
|
||||
// Apply penalty if critical normalized labels are missing
|
||||
let missingFieldsPenalty = 1.0;
|
||||
let missingCount = 0;
|
||||
if (!hasActualCost) missingCount++;
|
||||
if (!hasActualPrice) missingCount++;
|
||||
if (!hasLineDesc) missingCount++;
|
||||
|
||||
// Each missing field reduces confidence by 15%
|
||||
if (missingCount > 0) {
|
||||
missingFieldsPenalty = 1.0 - (missingCount * 0.15);
|
||||
}
|
||||
|
||||
avgConfidence = avgConfidence * missingFieldsPenalty;
|
||||
|
||||
return Math.round(avgConfidence * 100) / 100; // Round to 2 decimal places
|
||||
};
|
||||
|
||||
const calculateMatchConfidence = (matches, bestMatch) => {
|
||||
if (!matches || matches.length === 0 || !bestMatch) {
|
||||
return 0; // No match = 0% confidence
|
||||
}
|
||||
|
||||
// Base confidence from the match score
|
||||
// finalScore is already weighted and higher is better
|
||||
// Normalize it to a 0-100 scale
|
||||
const baseScore = Math.min(bestMatch.finalScore * 10, 100); // Scale factor of 10, cap at 100
|
||||
|
||||
// Bonus for multiple field matches (up to +15%)
|
||||
const fieldMatchBonus = Math.min(bestMatch.fieldMatches.length * 5, 15);
|
||||
|
||||
// Bonus for having price data (+10%)
|
||||
const priceDataBonus = bestMatch.hasPriceData ? 10 : 0;
|
||||
|
||||
// Bonus for clear winner (gap between 1st and 2nd match)
|
||||
let confidenceMarginBonus = 0;
|
||||
if (matches.length > 1) {
|
||||
const scoreDiff = bestMatch.finalScore - matches[1].finalScore;
|
||||
// If the best match is significantly better than the second best, add bonus
|
||||
confidenceMarginBonus = Math.min(scoreDiff * 5, 10); // Up to +10%
|
||||
} else {
|
||||
// Only one match found, add small bonus
|
||||
confidenceMarginBonus = 5;
|
||||
}
|
||||
|
||||
// Calculate total match confidence
|
||||
let matchConfidence = baseScore + fieldMatchBonus + priceDataBonus + confidenceMarginBonus;
|
||||
|
||||
// Cap at 100% and round to 2 decimal places
|
||||
matchConfidence = Math.min(Math.round(matchConfidence * 100) / 100, 100);
|
||||
|
||||
// Ensure minimum of 1% if there's any match at all
|
||||
return Math.max(matchConfidence, 1);
|
||||
};
|
||||
|
||||
const calculateOverallConfidence = (ocrConfidence, matchConfidence) => {
|
||||
// If there's no match, OCR confidence doesn't matter much
|
||||
if (matchConfidence === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Overall confidence is affected by both how well Textract read the data
|
||||
// and how well we matched it to existing joblines
|
||||
// Use a weighted average: 60% OCR confidence, 40% match confidence
|
||||
// OCR confidence is more important because even perfect match is useless without good OCR
|
||||
const overall = (ocrConfidence * 0.6) + (matchConfidence * 0.4);
|
||||
|
||||
return Math.round(overall * 100) / 100;
|
||||
};
|
||||
|
||||
// Helper function to merge and deduplicate results with weighted scoring
|
||||
const mergeResults = (resultsArray, weights = []) => {
|
||||
const scoreMap = new Map();
|
||||
|
||||
resultsArray.forEach((results, index) => {
|
||||
const weight = weights[index] || 1;
|
||||
results.forEach(result => {
|
||||
const id = result.item.id;
|
||||
const weightedScore = result.score * weight;
|
||||
|
||||
if (!scoreMap.has(id)) {
|
||||
scoreMap.set(id, { item: result.item, score: weightedScore, count: 1 });
|
||||
} else {
|
||||
const existing = scoreMap.get(id);
|
||||
// Lower score is better in Fuse.js, so take the minimum
|
||||
existing.score = Math.min(existing.score, weightedScore);
|
||||
existing.count++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert back to array and sort by score (lower is better)
|
||||
return Array.from(scoreMap.values())
|
||||
.sort((a, b) => {
|
||||
// Prioritize items found in multiple searches
|
||||
if (a.count !== b.count) return b.count - a.count;
|
||||
return a.score - b.score;
|
||||
})
|
||||
.slice(0, 5); // Return top 5 results
|
||||
};
|
||||
|
||||
async function generateBillFormData({ processedData, jobid: jobidFromProps, bodyshopid, partsorderid, req }) {
|
||||
const client = req.userGraphQLClient;
|
||||
|
||||
let jobid = jobidFromProps;
|
||||
//If no jobid, fetch it, and funnel it back.
|
||||
if (!jobid || jobid === null || jobid === undefined || jobid === "" || jobid === "null" || jobid === "undefined") {
|
||||
const ro_number = processedData.summary?.PO_NUMBER?.value || Object.values(processedData.summary).find(value => value.normalizedLabel === 'ro_number')?.value;
|
||||
if (!ro_number) {
|
||||
throw new Error("Could not find RO number in the extracted data to associate with the bill. Select an RO and try again.");
|
||||
}
|
||||
|
||||
const { jobs } = await client.request(`
|
||||
query QUERY_BILL_OCR_JOB_BY_RO($ro_number: String!) {
|
||||
jobs(where: {ro_number: {_eq: $ro_number}}) {
|
||||
id
|
||||
}
|
||||
}`, { ro_number });
|
||||
|
||||
if (jobs.length === 0) {
|
||||
throw new Error("No job found for the detected RO/PO number.");
|
||||
}
|
||||
jobid = jobs[0].id;
|
||||
}
|
||||
|
||||
const jobData = await client.request(`
|
||||
query QUERY_BILL_OCR_DATA($jobid: uuid!) {
|
||||
vendors {
|
||||
id
|
||||
name
|
||||
}
|
||||
jobs_by_pk(id: $jobid) {
|
||||
id
|
||||
bodyshop {
|
||||
id
|
||||
md_responsibility_centers
|
||||
cdk_dealerid
|
||||
pbs_serialnumber
|
||||
rr_dealerid
|
||||
}
|
||||
joblines {
|
||||
id
|
||||
line_desc
|
||||
removed
|
||||
act_price
|
||||
db_price
|
||||
oem_partno
|
||||
alt_partno
|
||||
part_type
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
`, {
|
||||
jobid, // TODO: Parts order IDs are currently ignore. If receving a parts order, it could be used to more precisely match to joblines.
|
||||
});
|
||||
|
||||
//Create fuses of line descriptions for matching.
|
||||
const jobLineDescFuse = new Fuse(
|
||||
jobData.jobs_by_pk.joblines.map(jl => ({ ...jl, line_desc_normalized: normalizeText(jl.line_desc || ""), oem_partno_normalized: normalizePartNumber(jl.oem_partno || ""), alt_partno_normalized: normalizePartNumber(jl.alt_partno || "") })),
|
||||
{
|
||||
keys: [{
|
||||
name: 'line_desc',
|
||||
weight: 6
|
||||
}, {
|
||||
name: 'oem_partno',
|
||||
weight: 8
|
||||
}, {
|
||||
name: 'alt_partno',
|
||||
weight: 5
|
||||
},
|
||||
{
|
||||
name: 'act_price',
|
||||
weight: 1
|
||||
},
|
||||
{
|
||||
name: 'line_desc_normalized',
|
||||
weight: 4
|
||||
},
|
||||
{
|
||||
name: 'oem_partno_normalized',
|
||||
weight: 6
|
||||
},
|
||||
{
|
||||
name: 'alt_partno_normalized',
|
||||
weight: 3
|
||||
}],
|
||||
threshold: 0.4, //Adjust as needed for matching sensitivity,
|
||||
includeScore: true,
|
||||
|
||||
}
|
||||
);
|
||||
const joblineMatches = joblineFuzzySearch({ fuseToSearch: jobLineDescFuse, processedData });
|
||||
|
||||
const vendorFuse = new Fuse(
|
||||
jobData.vendors,
|
||||
{
|
||||
keys: ['name'],
|
||||
threshold: 0.4, //Adjust as needed for matching sensitivity,
|
||||
includeScore: true,
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
const vendorMatches = vendorFuse.search(processedData.summary?.VENDOR_NAME?.value || processedData.summary?.NAME?.value);
|
||||
|
||||
let vendorid;
|
||||
if (vendorMatches.length > 0) {
|
||||
vendorid = vendorMatches[0].item.id;
|
||||
}
|
||||
const { jobs_by_pk: job } = jobData;
|
||||
if (!job) {
|
||||
throw new Error('Job not found for bill form data generation.');
|
||||
}
|
||||
|
||||
//TODO: How do we handle freight lines and core charges?
|
||||
//Create the form data structure for the bill posting screen.
|
||||
const billFormData = {
|
||||
"jobid": jobid,
|
||||
"vendorid": vendorid,
|
||||
"invoice_number": processedData.summary?.INVOICE_RECEIPT_ID?.value,
|
||||
"date": processedData.summary?.INVOICE_RECEIPT_DATE?.value,
|
||||
"is_credit_memo": false,
|
||||
"total": normalizePrice(processedData.summary?.INVOICE_TOTAL?.value || processedData.summary?.TOTAL?.value),
|
||||
"billlines": joblineMatches.map(jlMatchLine => {
|
||||
const { matches, textractLineItem, } = jlMatchLine
|
||||
//Matches should be pre-sorted, take the first one.
|
||||
const matchToUse = matches.length > 0 ? matches[0] : null;
|
||||
|
||||
// Calculate confidence scores
|
||||
const ocrConfidence = calculateTextractConfidence(textractLineItem);
|
||||
const matchConfidence = calculateMatchConfidence(matches, matchToUse);
|
||||
const overallConfidence = calculateOverallConfidence(ocrConfidence, matchConfidence);
|
||||
//TODO: Should be using the textract if there is an exact match on the normalized label.
|
||||
//if there isn't then we can do the below.
|
||||
|
||||
let actualPrice, actualCost;
|
||||
//TODO: What is several match on the normalized name? We need to pick the most likely one.
|
||||
const hasNormalizedActualPrice = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_price');
|
||||
const hasNormalizedActualCost = Object.keys(textractLineItem).find(key => textractLineItem[key].normalizedLabel === 'actual_cost');
|
||||
|
||||
if (hasNormalizedActualPrice) {
|
||||
actualPrice = textractLineItem[hasNormalizedActualPrice].value;
|
||||
}
|
||||
if (hasNormalizedActualCost) {
|
||||
actualCost = textractLineItem[hasNormalizedActualCost].value;
|
||||
}
|
||||
|
||||
if (!hasNormalizedActualPrice || !hasNormalizedActualCost) {
|
||||
//This is if there was no match found for normalized labels.
|
||||
//Check all prices, and generally the higher one will be the actual price and the lower one will be the cost.
|
||||
//Need to make sure that other random items are excluded. This should be within a reasonable range of the matched jobline at matchToUse.item.act_price
|
||||
//Iterate over all of the text values, and check out which of them are currencies.
|
||||
//They'll be in the format starting with a $ sign usually.
|
||||
const currencyTextractLineItems = [] // {key, value}
|
||||
Object.keys(textractLineItem).forEach(key => {
|
||||
const currencyValue = textractLineItem[key].value?.startsWith('$') ? textractLineItem[key].value : null;
|
||||
if (currencyValue) {
|
||||
//Clean it and parse it
|
||||
const cleanValue = parseFloat(currencyValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
currencyTextractLineItems.push({ key, value: cleanValue })
|
||||
}
|
||||
})
|
||||
|
||||
//Sort them descending
|
||||
currencyTextractLineItems.sort((a, b) => b.value - a.value);
|
||||
//Most expensive should be the actual price, second most expensive should be the cost.
|
||||
if (!actualPrice) actualPrice = currencyTextractLineItems.length > 0 ? currencyTextractLineItems[0].value : 0;
|
||||
if (!actualCost) actualCost = currencyTextractLineItems.length > 1 ? currencyTextractLineItems[1].value : 0;
|
||||
|
||||
if (matchToUse) {
|
||||
//Double check that they're within 50% of the matched jobline price if there is one.
|
||||
const joblinePrice = parseFloat(matchToUse.item.act_price) || 0;
|
||||
if (!hasNormalizedActualPrice && actualPrice > 0 && (actualPrice < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualPrice > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
|
||||
actualPrice = joblinePrice; //Set to the jobline as a fallback.
|
||||
}
|
||||
if (!hasNormalizedActualCost && actualCost > 0 && (actualCost < joblinePrice * (1 - PRICE_PERCENT_MARGIN_TOLERANCE) || actualCost > joblinePrice * (1 + PRICE_PERCENT_MARGIN_TOLERANCE))) {
|
||||
actualCost = null //Blank it out if it's not likely.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responsibilityCenters = job.bodyshop.md_responsibility_centers
|
||||
//TODO: Do we need to verify the lines to see if it is a unit price or total price (i.e. quantity * price)
|
||||
const lineObject = {
|
||||
"line_desc": matchToUse?.item?.line_desc || textractLineItem.ITEM?.value || "NO DESCRIPTION",
|
||||
"quantity": textractLineItem.QUANTITY?.value,
|
||||
"actual_price": normalizePrice(actualPrice),
|
||||
"actual_cost": normalizePrice(actualCost),
|
||||
"cost_center": matchToUse?.item?.part_type
|
||||
? bodyshopHasDmsKey(job.bodyshop)
|
||||
? matchToUse?.item?.part_type !== "PAE"
|
||||
? matchToUse?.item?.part_type
|
||||
: null
|
||||
: responsibilityCenters.defaults &&
|
||||
(responsibilityCenters.defaults.costs[matchToUse?.item?.part_type] || null)
|
||||
: null,
|
||||
"applicable_taxes": {
|
||||
"federal": InstanceManager({ imex: true, rome: false }),
|
||||
"state": false,
|
||||
"local": false
|
||||
},
|
||||
"joblineid": matchToUse?.item?.id || "noline",
|
||||
"confidence": `T${overallConfidence} - O${ocrConfidence} - J${matchConfidence}`
|
||||
}
|
||||
return lineObject
|
||||
})
|
||||
}
|
||||
|
||||
return billFormData
|
||||
|
||||
}
|
||||
|
||||
function joblineFuzzySearch({ fuseToSearch, processedData }) {
|
||||
const matches = []
|
||||
const searchStats = []; // Track search statistics
|
||||
|
||||
processedData.lineItems.forEach((lineItem, lineIndex) => {
|
||||
const lineStats = {
|
||||
lineNumber: lineIndex + 1,
|
||||
searches: []
|
||||
};
|
||||
|
||||
// Refined ITEM search (multi-word description)
|
||||
const refinedItemResults = (() => {
|
||||
if (!lineItem.ITEM?.value) return [];
|
||||
|
||||
const itemValue = lineItem.ITEM.value;
|
||||
const normalized = normalizeText(itemValue);
|
||||
|
||||
// 1: Full string search
|
||||
const fullSearch = fuseToSearch.search(normalized);
|
||||
lineStats.searches.push({ type: 'ITEM - Full String', term: normalized, results: fullSearch.length });
|
||||
|
||||
// 2: Search individual significant words (3+ chars)
|
||||
const words = normalized.split(' ').filter(w => w.length >= 3);
|
||||
const wordSearches = words.map(word => {
|
||||
const results = fuseToSearch.search(word);
|
||||
lineStats.searches.push({ type: 'ITEM - Individual Word', term: word, results: results.length });
|
||||
return results;
|
||||
});
|
||||
|
||||
// 3: Search without spaces entirely
|
||||
const noSpaceSearch = fuseToSearch.search(normalized.replace(/\s+/g, ''));
|
||||
lineStats.searches.push({ type: 'ITEM - No Spaces', term: normalized.replace(/\s+/g, ''), results: noSpaceSearch.length });
|
||||
|
||||
// Merge results with weights (full search weighted higher)
|
||||
return mergeResults(
|
||||
[fullSearch, ...wordSearches, noSpaceSearch],
|
||||
[1.0, ...words.map(() => 1.5), 1.2] // Full search best, individual words penalized slightly
|
||||
);
|
||||
})();
|
||||
|
||||
// Refined PRODUCT_CODE search (part numbers)
|
||||
const refinedProductCodeResults = (() => {
|
||||
if (!lineItem.PRODUCT_CODE?.value) return [];
|
||||
|
||||
const productCode = lineItem.PRODUCT_CODE.value;
|
||||
const normalized = normalizePartNumber(productCode);
|
||||
|
||||
// 1: Normalized search (no spaces/special chars)
|
||||
const normalizedSearch = fuseToSearch.search(normalized);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Normalized', term: normalized, results: normalizedSearch.length });
|
||||
|
||||
// 2: Original with minimal cleaning
|
||||
const minimalClean = productCode.replace(/\s+/g, '').toUpperCase();
|
||||
const minimalSearch = fuseToSearch.search(minimalClean);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Minimal Clean', term: minimalClean, results: minimalSearch.length });
|
||||
|
||||
// 3: Search with dashes (common in part numbers)
|
||||
const withDashes = productCode.replace(/[^a-zA-Z0-9-]/g, '').toUpperCase();
|
||||
const dashSearch = fuseToSearch.search(withDashes);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - With Dashes', term: withDashes, results: dashSearch.length });
|
||||
|
||||
// 4: Special chars to spaces (preserve word boundaries)
|
||||
const specialCharsToSpaces = productCode.replace(/[^a-zA-Z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim().toUpperCase();
|
||||
const specialCharsSearch = fuseToSearch.search(specialCharsToSpaces);
|
||||
lineStats.searches.push({ type: 'PRODUCT_CODE - Special Chars to Spaces', term: specialCharsToSpaces, results: specialCharsSearch.length });
|
||||
|
||||
return mergeResults(
|
||||
[normalizedSearch, minimalSearch, dashSearch, specialCharsSearch],
|
||||
[1.0, 1.1, 1.2, 1.15] // Prefer fully normalized, special chars to spaces slightly weighted
|
||||
);
|
||||
})();
|
||||
|
||||
// Refined PRICE search
|
||||
const refinedPriceResults = (() => {
|
||||
if (!lineItem.PRICE?.value) return [];
|
||||
|
||||
const price = normalizePrice(lineItem.PRICE.value);
|
||||
|
||||
// 1: Exact price match
|
||||
const exactSearch = fuseToSearch.search(price);
|
||||
lineStats.searches.push({ type: 'PRICE - Exact', term: price, results: exactSearch.length });
|
||||
|
||||
// 2: Price with 2 decimal places
|
||||
const priceFloat = parseFloat(price);
|
||||
if (!isNaN(priceFloat)) {
|
||||
const formattedPrice = priceFloat.toFixed(2);
|
||||
const formattedSearch = fuseToSearch.search(formattedPrice);
|
||||
lineStats.searches.push({ type: 'PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
|
||||
|
||||
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
|
||||
}
|
||||
|
||||
return exactSearch;
|
||||
})();
|
||||
|
||||
// Refined UNIT_PRICE search
|
||||
const refinedUnitPriceResults = (() => {
|
||||
if (!lineItem.UNIT_PRICE?.value) return [];
|
||||
|
||||
const unitPrice = normalizePrice(lineItem.UNIT_PRICE.value);
|
||||
|
||||
// 1: Exact price match
|
||||
const exactSearch = fuseToSearch.search(unitPrice);
|
||||
lineStats.searches.push({ type: 'UNIT_PRICE - Exact', term: unitPrice, results: exactSearch.length });
|
||||
|
||||
// 2: Price with 2 decimal places
|
||||
const priceFloat = parseFloat(unitPrice);
|
||||
if (!isNaN(priceFloat)) {
|
||||
const formattedPrice = priceFloat.toFixed(2);
|
||||
const formattedSearch = fuseToSearch.search(formattedPrice);
|
||||
lineStats.searches.push({ type: 'UNIT_PRICE - Formatted (2 decimals)', term: formattedPrice, results: formattedSearch.length });
|
||||
|
||||
return mergeResults([exactSearch, formattedSearch], [1.0, 1.1]);
|
||||
}
|
||||
|
||||
return exactSearch;
|
||||
})();
|
||||
|
||||
//Merge them all together and sort by the highest scores.
|
||||
const combinedScoreMap = new Map();
|
||||
|
||||
// Weight different field types differently
|
||||
const fieldWeights = {
|
||||
productCode: 5.0, // Most important - part numbers should match
|
||||
item: 3.0, // Second most important - description
|
||||
price: 1.0, // Less important - prices can vary
|
||||
unitPrice: 0.8 // Least important - similar to price
|
||||
};
|
||||
|
||||
[
|
||||
{ results: refinedProductCodeResults, weight: fieldWeights.productCode, field: 'productCode' },
|
||||
{ results: refinedItemResults, weight: fieldWeights.item, field: 'item' },
|
||||
{ results: refinedPriceResults, weight: fieldWeights.price, field: 'price' },
|
||||
{ results: refinedUnitPriceResults, weight: fieldWeights.unitPrice, field: 'unitPrice' }
|
||||
].forEach(({ results, weight, field }) => {
|
||||
results.forEach((result, index) => {
|
||||
const id = result.item.id;
|
||||
|
||||
// Position bonus (first result is better than fifth)
|
||||
const positionBonus = (5 - index) / 5;
|
||||
|
||||
// Lower score is better in Fuse.js, so invert it and apply weights
|
||||
const normalizedScore = (1 - result.score) * weight * positionBonus;
|
||||
|
||||
if (!combinedScoreMap.has(id)) {
|
||||
combinedScoreMap.set(id, {
|
||||
item: result.item,
|
||||
score: normalizedScore,
|
||||
fieldMatches: [field],
|
||||
matchCount: result.count || 1
|
||||
});
|
||||
} else {
|
||||
const existing = combinedScoreMap.get(id);
|
||||
existing.score += normalizedScore;
|
||||
existing.fieldMatches.push(field);
|
||||
existing.matchCount += (result.count || 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Convert to array and sort by best combined score
|
||||
const finalMatches = Array.from(combinedScoreMap.values())
|
||||
.map(entry => {
|
||||
// Apply penalty if item has no act_price or it's 0
|
||||
const hasPriceData = entry.item.act_price && parseFloat(entry.item.act_price) > 0;
|
||||
const priceDataPenalty = hasPriceData ? 1.0 : 0.5; // 50% penalty if no price
|
||||
|
||||
return {
|
||||
...entry,
|
||||
// Boost score for items that matched in multiple fields, penalize for missing price
|
||||
finalScore: entry.score * (1 + (entry.fieldMatches.length * 0.2)) * priceDataPenalty,
|
||||
hasPriceData
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.finalScore - a.finalScore)
|
||||
.slice(0, 5);
|
||||
|
||||
// Always push the textract line item, even if no matches found
|
||||
// This ensures all invoice lines are processed
|
||||
matches.push({
|
||||
matches: finalMatches,
|
||||
textractLineItem: lineItem,
|
||||
hasMatch: finalMatches.length > 0
|
||||
});
|
||||
|
||||
searchStats.push(lineStats);
|
||||
|
||||
})
|
||||
|
||||
// // Output search statistics table
|
||||
// console.log('\n═══════════════════════════════════════════════════════════════════════');
|
||||
// console.log(' FUSE.JS SEARCH STATISTICS');
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════\n');
|
||||
|
||||
// searchStats.forEach(lineStat => {
|
||||
// console.log(`📄 Line Item #${lineStat.lineNumber}:`);
|
||||
// console.log('─'.repeat(75));
|
||||
|
||||
// if (lineStat.searches.length > 0) {
|
||||
// const tableData = lineStat.searches.map(search => ({
|
||||
// 'Search Type': search.type,
|
||||
// 'Search Term': search.term.substring(0, 40) + (search.term.length > 40 ? '...' : ''),
|
||||
// 'Results': search.results
|
||||
// }));
|
||||
// console.table(tableData);
|
||||
// } else {
|
||||
// console.log(' No searches performed for this line item.\n');
|
||||
// }
|
||||
// });
|
||||
|
||||
// // Summary statistics
|
||||
// const totalSearches = searchStats.reduce((sum, stat) => sum + stat.searches.length, 0);
|
||||
// const totalResults = searchStats.reduce((sum, stat) =>
|
||||
// sum + stat.searches.reduce((s, search) => s + search.results, 0), 0);
|
||||
// const avgResultsPerSearch = totalSearches > 0 ? (totalResults / totalSearches).toFixed(2) : 0;
|
||||
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════');
|
||||
// console.log(' SUMMARY');
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════');
|
||||
// console.table({
|
||||
// 'Total Line Items': processedData.lineItems.length,
|
||||
// 'Total Searches Performed': totalSearches,
|
||||
// 'Total Results Found': totalResults,
|
||||
// 'Average Results per Search': avgResultsPerSearch
|
||||
// });
|
||||
// console.log('═══════════════════════════════════════════════════════════════════════\n');
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
const bodyshopHasDmsKey = (bodyshop) =>
|
||||
bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber || bodyshop.rr_dealerid;
|
||||
|
||||
|
||||
module.exports = {
|
||||
generateBillFormData
|
||||
}
|
||||
159
server/ai/bill-ocr/bill-ocr-helpers.js
Normal file
159
server/ai/bill-ocr/bill-ocr-helpers.js
Normal file
@@ -0,0 +1,159 @@
|
||||
const PDFDocument = require('pdf-lib').PDFDocument;
|
||||
const logger = require("../../utils/logger");
|
||||
const TEXTRACT_REDIS_PREFIX = `textract:${process.env?.NODE_ENV}`
|
||||
const TEXTRACT_JOB_TTL = 10 * 60;
|
||||
|
||||
|
||||
/**
|
||||
* Generate Redis key for Textract job using textract job ID
|
||||
* @param {string} textractJobId
|
||||
* @returns {string}
|
||||
*/
|
||||
function getTextractJobKey(textractJobId) {
|
||||
return `${TEXTRACT_REDIS_PREFIX}:${textractJobId}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Store Textract job data in Redis
|
||||
* @param {string} textractJobId
|
||||
* @param {Object} redisPubClient
|
||||
* @param {Object} jobData
|
||||
*/
|
||||
async function setTextractJob({ redisPubClient, textractJobId, jobData }) {
|
||||
if (!redisPubClient) {
|
||||
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
|
||||
}
|
||||
const key = getTextractJobKey(textractJobId);
|
||||
await redisPubClient.set(key, JSON.stringify(jobData));
|
||||
await redisPubClient.expire(key, TEXTRACT_JOB_TTL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve Textract job data from Redis
|
||||
* @param {string} textractJobId
|
||||
* @param {Object} redisPubClient
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function getTextractJob({ redisPubClient, textractJobId }) {
|
||||
if (!redisPubClient) {
|
||||
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
|
||||
}
|
||||
const key = getTextractJobKey(textractJobId);
|
||||
const data = await redisPubClient.get(key);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect file type based on MIME type and file signature
|
||||
* @param {Object} file - Multer file object
|
||||
* @returns {string} 'pdf', 'image', or 'unknown'
|
||||
*/
|
||||
function getFileType(file) {
|
||||
// Check MIME type first
|
||||
const mimeType = file.mimetype?.toLowerCase();
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
if (mimeType && mimeType.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// Fallback: Check file signature (magic bytes)
|
||||
const buffer = file.buffer;
|
||||
if (buffer && buffer.length > 4) {
|
||||
// PDF signature: %PDF
|
||||
if (buffer[0] === 0x25 && buffer[1] === 0x50 && buffer[2] === 0x44 && buffer[3] === 0x46) {
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
// JPEG signature: FF D8 FF
|
||||
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// PNG signature: 89 50 4E 47
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
// HEIC/HEIF: Check for ftyp followed by heic/heix/hevc/hevx
|
||||
if (buffer.length > 12) {
|
||||
const ftypIndex = buffer.indexOf(Buffer.from('ftyp'));
|
||||
if (ftypIndex > 0 && ftypIndex < 12) {
|
||||
const brand = buffer.slice(ftypIndex + 4, ftypIndex + 8).toString('ascii');
|
||||
if (brand.startsWith('heic') || brand.startsWith('heix') ||
|
||||
brand.startsWith('hevc') || brand.startsWith('hevx') ||
|
||||
brand.startsWith('mif1')) {
|
||||
return 'image';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pages in a PDF buffer
|
||||
* @param {Buffer} pdfBuffer
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async function getPdfPageCount(pdfBuffer) {
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.load(pdfBuffer);
|
||||
return pdfDoc.getPageCount();
|
||||
} catch (error) {
|
||||
console.error('Error reading PDF page count:', error);
|
||||
throw new Error('Failed to read PDF: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any jobs in IN_PROGRESS status
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function hasActiveJobs({ redisPubClient }) {
|
||||
if (!redisPubClient) {
|
||||
throw new Error('Redis client not initialized.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all textract job keys
|
||||
const pattern = `${TEXTRACT_REDIS_PREFIX}:*`;
|
||||
const keys = await redisPubClient.keys(pattern);
|
||||
|
||||
if (!keys || keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
//TODO: Is there a better way to do this that supports clusters?
|
||||
// Check if any job has IN_PROGRESS status
|
||||
for (const key of keys) {
|
||||
const data = await redisPubClient.get(key);
|
||||
if (data) {
|
||||
const jobData = JSON.parse(data);
|
||||
if (jobData.status === 'IN_PROGRESS') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-job-check-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getTextractJobKey,
|
||||
setTextractJob,
|
||||
getTextractJob,
|
||||
getFileType,
|
||||
getPdfPageCount,
|
||||
hasActiveJobs,
|
||||
TEXTRACT_REDIS_PREFIX
|
||||
}
|
||||
|
||||
200
server/ai/bill-ocr/bill-ocr-normalize.js
Normal file
200
server/ai/bill-ocr/bill-ocr-normalize.js
Normal file
@@ -0,0 +1,200 @@
|
||||
|
||||
const MIN_CONFIDENCE_VALUE = 50
|
||||
|
||||
function normalizeFieldName(fieldType) {
|
||||
//Placeholder normalization for now.
|
||||
return fieldType;
|
||||
}
|
||||
|
||||
const standardizedFieldsnames = {
|
||||
actual_cost: "actual_cost",
|
||||
actual_price: "actual_price",
|
||||
line_desc: "line_desc",
|
||||
quantity: "quantity",
|
||||
part_no: "part_no",
|
||||
ro_number: "ro_number",
|
||||
}
|
||||
|
||||
function normalizeLabelName(labelText) {
|
||||
if (!labelText) return '';
|
||||
|
||||
// Convert to lowercase and trim whitespace
|
||||
let normalized = labelText.toLowerCase().trim();
|
||||
|
||||
// Remove special characters and replace spaces with underscores
|
||||
normalized = normalized.replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, '_');
|
||||
|
||||
|
||||
// Common label normalizations
|
||||
const labelMap = {
|
||||
'qty': standardizedFieldsnames.quantity,
|
||||
'qnty': standardizedFieldsnames.quantity,
|
||||
'sale_qty': standardizedFieldsnames.quantity,
|
||||
'invoiced_qty': standardizedFieldsnames.quantity,
|
||||
'qty_shipped': standardizedFieldsnames.quantity,
|
||||
'quantity': standardizedFieldsnames.quantity,
|
||||
'filled': standardizedFieldsnames.quantity,
|
||||
'count': standardizedFieldsnames.quantity,
|
||||
'quant': standardizedFieldsnames.quantity,
|
||||
'desc': standardizedFieldsnames.line_desc,
|
||||
'description': standardizedFieldsnames.line_desc,
|
||||
'item': standardizedFieldsnames.line_desc,
|
||||
'part': standardizedFieldsnames.part_no,
|
||||
'part_no': standardizedFieldsnames.part_no,
|
||||
'part_num': standardizedFieldsnames.part_no,
|
||||
'part_number': standardizedFieldsnames.part_no,
|
||||
'item_no': standardizedFieldsnames.part_no,
|
||||
'price': standardizedFieldsnames.actual_price,
|
||||
//'amount': standardizedFieldsnames.actual_price,
|
||||
'list_price': standardizedFieldsnames.actual_price,
|
||||
'unit_price': standardizedFieldsnames.actual_price,
|
||||
'list': standardizedFieldsnames.actual_price,
|
||||
'retail_price': standardizedFieldsnames.actual_price,
|
||||
'net': standardizedFieldsnames.actual_cost,
|
||||
'selling_price': standardizedFieldsnames.actual_cost,
|
||||
'net_price': standardizedFieldsnames.actual_cost,
|
||||
'net_cost': standardizedFieldsnames.actual_cost,
|
||||
'po_no': standardizedFieldsnames.ro_number,
|
||||
'customer_po_no': standardizedFieldsnames.ro_number,
|
||||
'customer_po_no_': standardizedFieldsnames.ro_number
|
||||
|
||||
};
|
||||
|
||||
return labelMap[normalized] || `NOT_MAPPED => ${normalized}`; // TODO: Should we monitor unmapped labels?
|
||||
}
|
||||
|
||||
function processScanData(invoiceData) {
|
||||
// Process and clean the extracted data
|
||||
const processed = {
|
||||
summary: {},
|
||||
lineItems: []
|
||||
};
|
||||
|
||||
// Clean summary fields
|
||||
for (const [key, value] of Object.entries(invoiceData.summary)) {
|
||||
if (value.confidence > MIN_CONFIDENCE_VALUE) { // Only include fields with > 50% confidence
|
||||
processed.summary[key] = {
|
||||
value: value.value,
|
||||
label: value.label,
|
||||
normalizedLabel: value.normalizedLabel,
|
||||
confidence: value.confidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process line items
|
||||
processed.lineItems = invoiceData.lineItems
|
||||
.map(item => {
|
||||
const processedItem = {};
|
||||
|
||||
for (const [key, value] of Object.entries(item)) {
|
||||
if (value.confidence > MIN_CONFIDENCE_VALUE) { // Only include fields with > 50% confidence
|
||||
let cleanValue = value.value;
|
||||
|
||||
// Parse numbers for quantity and price fields
|
||||
if (key === 'quantity') {
|
||||
cleanValue = parseFloat(cleanValue) || 0;
|
||||
} else if (key === 'retail_price' || key === 'actual_price') {
|
||||
// Remove currency symbols and parse
|
||||
cleanValue = parseFloat(cleanValue.replace(/[^0-9.-]/g, '')) || 0;
|
||||
}
|
||||
|
||||
processedItem[key] = {
|
||||
value: cleanValue,
|
||||
label: value.label,
|
||||
normalizedLabel: value.normalizedLabel,
|
||||
confidence: value.confidence
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return processedItem;
|
||||
})
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
function extractInvoiceData(textractResponse) {
|
||||
const invoiceData = {
|
||||
summary: {},
|
||||
lineItems: []
|
||||
};
|
||||
|
||||
if (!textractResponse.ExpenseDocuments || textractResponse.ExpenseDocuments.length === 0) {
|
||||
return invoiceData;
|
||||
}
|
||||
|
||||
// Process each page of the invoice
|
||||
textractResponse.ExpenseDocuments.forEach(expenseDoc => {
|
||||
// Extract summary fields (vendor, invoice number, date, total, etc.)
|
||||
if (expenseDoc.SummaryFields) {
|
||||
expenseDoc.SummaryFields.forEach(field => {
|
||||
const fieldType = field.Type?.Text || '';
|
||||
const fieldValue = field.ValueDetection?.Text || '';
|
||||
const fieldLabel = field.LabelDetection?.Text || '';
|
||||
const confidence = field.ValueDetection?.Confidence || 0;
|
||||
|
||||
// Map common invoice fields
|
||||
if (fieldType && fieldValue) {
|
||||
invoiceData.summary[fieldType] = {
|
||||
value: fieldValue,
|
||||
label: fieldLabel,
|
||||
normalizedLabel: normalizeLabelName(fieldLabel),
|
||||
confidence: confidence
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract line items
|
||||
if (expenseDoc.LineItemGroups) {
|
||||
expenseDoc.LineItemGroups.forEach(lineItemGroup => {
|
||||
if (lineItemGroup.LineItems) {
|
||||
lineItemGroup.LineItems.forEach(lineItem => {
|
||||
const item = {};
|
||||
const fieldNameCounts = {}; // Track field name occurrences
|
||||
|
||||
if (lineItem.LineItemExpenseFields) {
|
||||
lineItem.LineItemExpenseFields.forEach(field => {
|
||||
const fieldType = field.Type?.Text || '';
|
||||
const fieldValue = field.ValueDetection?.Text || '';
|
||||
const fieldLabel = field.LabelDetection?.Text || '';
|
||||
const confidence = field.ValueDetection?.Confidence || 0;
|
||||
|
||||
if (fieldType && fieldValue) {
|
||||
// Normalize field names
|
||||
let normalizedField = normalizeFieldName(fieldType);
|
||||
|
||||
// Ensure uniqueness by appending a counter if the field already exists
|
||||
if (Object.prototype.hasOwnProperty.call(item, normalizedField)) {
|
||||
fieldNameCounts[normalizedField] = (fieldNameCounts[normalizedField] || 1) + 1;
|
||||
normalizedField = `${normalizedField}_${fieldNameCounts[normalizedField]}`;
|
||||
}
|
||||
|
||||
item[normalizedField] = {
|
||||
value: fieldValue,
|
||||
label: fieldLabel,
|
||||
normalizedLabel: normalizeLabelName(fieldLabel),
|
||||
confidence: confidence
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(item).length > 0) {
|
||||
invoiceData.lineItems.push(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return invoiceData;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractInvoiceData,
|
||||
processScanData,
|
||||
standardizedFieldsnames
|
||||
}
|
||||
8
server/ai/bill-ocr/bill-ocr-readme.md
Normal file
8
server/ai/bill-ocr/bill-ocr-readme.md
Normal file
@@ -0,0 +1,8 @@
|
||||
Required Infrastructure setup
|
||||
1. Create an AI user that has access to the required S3 buckets and textract permissions.
|
||||
2. Had to create a queue and SNS topic. had to also create the role that had `sns:Publish`. Had to add `sqs:ReceiveMessage` and `sqs:DeleteMessage` to the profile.
|
||||
3. Created 2 roles for SNS. The textract role is the right one, the other was created manually based on incorrect instructions.
|
||||
|
||||
TODO:
|
||||
* Create a rome bucket for uploads, or move to the regular spot.
|
||||
* Add environment variables.
|
||||
464
server/ai/bill-ocr/bill-ocr.js
Normal file
464
server/ai/bill-ocr/bill-ocr.js
Normal file
@@ -0,0 +1,464 @@
|
||||
const { TextractClient, StartExpenseAnalysisCommand, GetExpenseAnalysisCommand, AnalyzeExpenseCommand } = require("@aws-sdk/client-textract");
|
||||
const { S3Client, PutObjectCommand } = require("@aws-sdk/client-s3");
|
||||
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require("@aws-sdk/client-sqs");
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { getTextractJobKey, setTextractJob, getTextractJob, getFileType, getPdfPageCount, hasActiveJobs } = require("./bill-ocr-helpers");
|
||||
const { extractInvoiceData, processScanData } = require("./bill-ocr-normalize");
|
||||
const { generateBillFormData } = require("./bill-ocr-generator");
|
||||
const logger = require("../../utils/logger");
|
||||
|
||||
// Initialize AWS clients
|
||||
const awsConfig = {
|
||||
region: process.env.AWS_AI_REGION || "ca-central-1",
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_AI_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.AWS_AI_SECRET_ACCESS_KEY,
|
||||
}
|
||||
};
|
||||
|
||||
const textractClient = new TextractClient(awsConfig);
|
||||
const s3Client = new S3Client(awsConfig);
|
||||
const sqsClient = new SQSClient(awsConfig);
|
||||
|
||||
let redisPubClient = null;
|
||||
|
||||
|
||||
/**
|
||||
* Initialize the bill-ocr module with Redis client
|
||||
* @param {Object} pubClient - Redis cluster client
|
||||
*/
|
||||
function initializeBillOcr(pubClient) {
|
||||
redisPubClient = pubClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if job exists by Textract job ID
|
||||
* @param {string} textractJobId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function jobExists(textractJobId) {
|
||||
if (!redisPubClient) {
|
||||
throw new Error('Redis client not initialized. Call initializeBillOcr first.');
|
||||
}
|
||||
const key = getTextractJobKey(textractJobId);
|
||||
const exists = await redisPubClient.exists(key);
|
||||
|
||||
if (exists) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function handleBillOcr(req, res) {
|
||||
// Check if file was uploaded
|
||||
if (!req.file) {
|
||||
return res.status(400).send({ error: 'No file uploaded.' });
|
||||
}
|
||||
|
||||
// The uploaded file is available in request file
|
||||
const uploadedFile = req.file;
|
||||
const { jobid, bodyshopid, partsorderid } = req.body;
|
||||
logger.log("bill-ocr-start", "DEBUG", req.user.email, jobid, null);
|
||||
|
||||
try {
|
||||
const fileType = getFileType(uploadedFile);
|
||||
// Images are always processed synchronously (single page)
|
||||
if (fileType === 'image') {
|
||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
status: 'COMPLETED',
|
||||
data: { ...processedData, billForm },
|
||||
message: 'Invoice processing completed'
|
||||
});
|
||||
} else if (fileType === 'pdf') {
|
||||
// Check the number of pages in the PDF
|
||||
const pageCount = await getPdfPageCount(uploadedFile.buffer);
|
||||
|
||||
if (pageCount === 1) {
|
||||
// Process synchronously for single-page documents
|
||||
const processedData = await processSinglePageDocument(uploadedFile.buffer);
|
||||
const billForm = await generateBillFormData({ processedData: processedData, jobid, bodyshopid, partsorderid, req: req });
|
||||
logger.log("bill-ocr-single-complete", "DEBUG", req.user.email, jobid, { ...processedData, billForm });
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
status: 'COMPLETED',
|
||||
data: { ...processedData, billForm },
|
||||
message: 'Invoice processing completed'
|
||||
});
|
||||
}
|
||||
// Start the Textract job (non-blocking) for multi-page documents
|
||||
const jobInfo = await startTextractJob(uploadedFile.buffer, { jobid, bodyshopid, partsorderid });
|
||||
logger.log("bill-ocr-multipage-start", "DEBUG", req.user.email, jobid, jobInfo);
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
textractJobId: jobInfo.jobId,
|
||||
message: 'Invoice processing started',
|
||||
statusUrl: `/ai/bill-ocr/status/${jobInfo.jobId}`
|
||||
});
|
||||
|
||||
} else {
|
||||
logger.log("bill-ocr-unsupported-filetype", "WARN", req.user.email, jobid, { fileType });
|
||||
|
||||
return res.status(400).json({
|
||||
error: 'Unsupported file type',
|
||||
message: 'Please upload a PDF or supported image file (JPEG, PNG, TIFF)'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-error", "ERROR", req.user.email, jobid, { error: error.message, stack: error.stack });
|
||||
return res.status(500).json({
|
||||
error: 'Failed to start invoice processing',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBillOcrStatus(req, res) {
|
||||
const { textractJobId } = req.params;
|
||||
|
||||
if (!textractJobId) {
|
||||
logger.log("bill-ocr-status-error", "WARN", req.user.email, null, { error: 'No textractJobId found in params' });
|
||||
return res.status(400).json({ error: 'Job ID is required' });
|
||||
|
||||
}
|
||||
const jobStatus = await getTextractJob({ redisPubClient, textractJobId });
|
||||
|
||||
if (!jobStatus) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
if (jobStatus.status === 'COMPLETED') {
|
||||
// Generate billForm on-demand if not already generated
|
||||
let billForm = jobStatus.data?.billForm;
|
||||
|
||||
if (!billForm && jobStatus.context) {
|
||||
try {
|
||||
billForm = await generateBillFormData({
|
||||
processedData: jobStatus.data,
|
||||
jobid: jobStatus.context.jobid,
|
||||
bodyshopid: jobStatus.context.bodyshopid,
|
||||
partsorderid: jobStatus.context.partsorderid,
|
||||
req: req // Now we have request context!
|
||||
});
|
||||
logger.log("bill-ocr-multipage-complete", "DEBUG", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, billForm });
|
||||
|
||||
// Cache the billForm back to Redis for future requests
|
||||
await setTextractJob({
|
||||
redisPubClient,
|
||||
textractJobId,
|
||||
jobData: {
|
||||
...jobStatus,
|
||||
data: {
|
||||
...jobStatus.data,
|
||||
billForm
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-multipage-error", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: error.message, stack: error.stack });
|
||||
|
||||
return res.status(500).send({
|
||||
status: 'COMPLETED',
|
||||
error: 'Data processed but failed to generate bill form',
|
||||
message: error.message,
|
||||
data: jobStatus.data // Still return the raw processed data
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
status: 'COMPLETED',
|
||||
data: {
|
||||
...jobStatus.data,
|
||||
billForm
|
||||
}
|
||||
});
|
||||
} else if (jobStatus.status === 'FAILED') {
|
||||
logger.log("bill-ocr-multipage-failed", "ERROR", req.user.email, jobStatus.context.jobid, { ...jobStatus.data, error: jobStatus.error, });
|
||||
|
||||
return res.status(500).json({
|
||||
status: 'FAILED',
|
||||
error: jobStatus.error
|
||||
});
|
||||
} else {
|
||||
return res.status(200).json({
|
||||
status: jobStatus.status
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single-page document synchronously using AnalyzeExpenseCommand
|
||||
* @param {Buffer} pdfBuffer
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function processSinglePageDocument(pdfBuffer) {
|
||||
const analyzeCommand = new AnalyzeExpenseCommand({
|
||||
Document: {
|
||||
Bytes: pdfBuffer
|
||||
}
|
||||
});
|
||||
|
||||
const result = await textractClient.send(analyzeCommand);
|
||||
const invoiceData = extractInvoiceData(result);
|
||||
const processedData = processScanData(invoiceData);
|
||||
|
||||
return {
|
||||
...processedData,
|
||||
originalTextractResponse: result
|
||||
};
|
||||
}
|
||||
|
||||
async function startTextractJob(pdfBuffer, context = {}) {
|
||||
// Upload PDF to S3 temporarily for Textract async processing
|
||||
const { bodyshopid, jobid } = context;
|
||||
const s3Bucket = process.env.AWS_AI_BUCKET;
|
||||
const snsTopicArn = process.env.AWS_TEXTRACT_SNS_TOPIC_ARN;
|
||||
const snsRoleArn = process.env.AWS_TEXTRACT_SNS_ROLE_ARN;
|
||||
|
||||
if (!s3Bucket) {
|
||||
throw new Error('AWS_AI_BUCKET environment variable is required');
|
||||
}
|
||||
if (!snsTopicArn) {
|
||||
throw new Error('AWS_TEXTRACT_SNS_TOPIC_ARN environment variable is required');
|
||||
}
|
||||
if (!snsRoleArn) {
|
||||
throw new Error('AWS_TEXTRACT_SNS_ROLE_ARN environment variable is required');
|
||||
}
|
||||
|
||||
const uploadId = uuidv4();
|
||||
const s3Key = `textract-temp/${bodyshopid}/${jobid}/${uploadId}.pdf`; //TODO Update Keys structure to something better.
|
||||
|
||||
// Upload to S3
|
||||
const uploadCommand = new PutObjectCommand({
|
||||
Bucket: s3Bucket,
|
||||
Key: s3Key,
|
||||
Body: pdfBuffer,
|
||||
ContentType: 'application/pdf' //Hard coded - we only support PDFs for multi-page
|
||||
});
|
||||
await s3Client.send(uploadCommand);
|
||||
|
||||
// Start async Textract expense analysis with SNS notification
|
||||
const startCommand = new StartExpenseAnalysisCommand({
|
||||
DocumentLocation: {
|
||||
S3Object: {
|
||||
Bucket: s3Bucket,
|
||||
Name: s3Key
|
||||
}
|
||||
},
|
||||
NotificationChannel: {
|
||||
SNSTopicArn: snsTopicArn,
|
||||
RoleArn: snsRoleArn
|
||||
},
|
||||
ClientRequestToken: uploadId
|
||||
});
|
||||
|
||||
const startResult = await textractClient.send(startCommand);
|
||||
const textractJobId = startResult.JobId;
|
||||
|
||||
// Store job info in Redis using textractJobId as the key
|
||||
await setTextractJob(
|
||||
{
|
||||
redisPubClient,
|
||||
textractJobId,
|
||||
jobData: {
|
||||
status: 'IN_PROGRESS',
|
||||
s3Key: s3Key,
|
||||
uploadId: uploadId,
|
||||
startedAt: new Date().toISOString(),
|
||||
context: context // Store the context for later use
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: textractJobId
|
||||
};
|
||||
}
|
||||
|
||||
// Process SQS messages from Textract completion notifications
|
||||
async function processSQSMessages() {
|
||||
const queueUrl = process.env.AWS_TEXTRACT_SQS_QUEUE_URL;
|
||||
|
||||
if (!queueUrl) {
|
||||
logger.log("bill-ocr-error", "ERROR", "api", null, { message: "AWS_TEXTRACT_SQS_QUEUE_URL not configured" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Only poll if there are active mutli page jobs in progress
|
||||
const hasActive = await hasActiveJobs({ redisPubClient });
|
||||
if (!hasActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const receiveCommand = new ReceiveMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
MaxNumberOfMessages: 10,
|
||||
WaitTimeSeconds: 20,
|
||||
MessageAttributeNames: ['All']
|
||||
});
|
||||
|
||||
const result = await sqsClient.send(receiveCommand);
|
||||
|
||||
if (result.Messages && result.Messages.length > 0) {
|
||||
logger.log("bill-ocr-sqs-processing", "DEBUG", "api", null, { message: `Processing ${result.Messages.length} messages from SQS` });
|
||||
for (const message of result.Messages) {
|
||||
try {
|
||||
// Environment-level filtering: check if this message belongs to this environment
|
||||
const shouldProcess = await shouldProcessMessage(message);
|
||||
|
||||
if (shouldProcess) {
|
||||
await handleTextractNotification(message);
|
||||
// Delete message after successful processing
|
||||
const deleteCommand = new DeleteMessageCommand({
|
||||
QueueUrl: queueUrl,
|
||||
ReceiptHandle: message.ReceiptHandle
|
||||
});
|
||||
await sqsClient.send(deleteCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
logger.log("bill-ocr-sqs-processing-error", "ERROR", "api", null, { message, error: error.message, stack: error.stack });
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-sqs-receiving-error", "ERROR", "api", null, { error: error.message, stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message should be processed by this environment
|
||||
* @param {Object} message - SQS message
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function shouldProcessMessage(message) {
|
||||
try {
|
||||
const body = JSON.parse(message.Body);
|
||||
const snsMessage = JSON.parse(body.Message);
|
||||
const textractJobId = snsMessage.JobId;
|
||||
|
||||
// Check if job exists in Redis for this environment (using environment-specific prefix)
|
||||
const exists = await jobExists(textractJobId);
|
||||
return exists;
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-message-check-error", "DEBUG", "api", null, { message: "Error checking if message should be processed", error: error.message, stack: error.stack });
|
||||
// If we can't parse the message, don't process it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextractNotification(message) {
|
||||
const body = JSON.parse(message.Body);
|
||||
let snsMessage
|
||||
try {
|
||||
snsMessage = JSON.parse(body.Message);
|
||||
} catch (error) {
|
||||
logger.log("bill-ocr-handle-textract-error", "DEBUG", "api", null, { message: "Error parsing SNS message - invalid message format.", error: error.message, stack: error.stack, body });
|
||||
return;
|
||||
}
|
||||
|
||||
const textractJobId = snsMessage.JobId;
|
||||
const status = snsMessage.Status;
|
||||
|
||||
// Get job info from Redis
|
||||
const jobInfo = await getTextractJob({ redisPubClient, textractJobId });
|
||||
|
||||
if (!jobInfo) {
|
||||
logger.log("bill-ocr-job-not-found", "DEBUG", "api", null, { message: `Job info not found in Redis for Textract job ID: ${textractJobId}`, textractJobId, snsMessage });
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'SUCCEEDED') {
|
||||
// Retrieve the results
|
||||
const { processedData, originalResponse } = await retrieveTextractResults(textractJobId);
|
||||
|
||||
// Store the processed data - billForm will be generated on-demand in the status endpoint
|
||||
await setTextractJob(
|
||||
{
|
||||
redisPubClient,
|
||||
textractJobId,
|
||||
jobData: {
|
||||
...jobInfo,
|
||||
status: 'COMPLETED',
|
||||
data: {
|
||||
...processedData,
|
||||
originalTextractResponse: originalResponse
|
||||
},
|
||||
completedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
);
|
||||
} else if (status === 'FAILED') {
|
||||
await setTextractJob(
|
||||
{
|
||||
redisPubClient,
|
||||
textractJobId,
|
||||
jobData: {
|
||||
...jobInfo,
|
||||
status: 'FAILED',
|
||||
error: snsMessage.StatusMessage || 'Textract job failed',
|
||||
completedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function retrieveTextractResults(textractJobId) {
|
||||
// Handle pagination if there are multiple pages of results
|
||||
let allExpenseDocuments = [];
|
||||
let nextToken = null;
|
||||
|
||||
do {
|
||||
const getCommand = new GetExpenseAnalysisCommand({
|
||||
JobId: textractJobId,
|
||||
NextToken: nextToken
|
||||
});
|
||||
|
||||
const result = await textractClient.send(getCommand);
|
||||
|
||||
if (result.ExpenseDocuments) {
|
||||
allExpenseDocuments = allExpenseDocuments.concat(result.ExpenseDocuments);
|
||||
}
|
||||
|
||||
nextToken = result.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
// Store the complete original response
|
||||
const fullTextractResponse = { ExpenseDocuments: allExpenseDocuments };
|
||||
|
||||
// Extract invoice data from Textract response
|
||||
const invoiceData = extractInvoiceData(fullTextractResponse);
|
||||
|
||||
return {
|
||||
processedData: processScanData(invoiceData),
|
||||
originalResponse: fullTextractResponse
|
||||
};
|
||||
}
|
||||
|
||||
// Start SQS polling (call this when server starts)
|
||||
function startSQSPolling() {
|
||||
const pollInterval = setInterval(() => {
|
||||
processSQSMessages().catch(error => {
|
||||
logger.log("bill-ocr-sqs-poll-error", "ERROR", "api", null, { message: error.message, stack: error.stack });
|
||||
});
|
||||
}, 10000); // Poll every 10 seconds
|
||||
return pollInterval;
|
||||
}
|
||||
|
||||
|
||||
module.exports = {
|
||||
initializeBillOcr,
|
||||
handleBillOcr,
|
||||
handleBillOcrStatus,
|
||||
startSQSPolling
|
||||
};
|
||||
@@ -8,6 +8,5 @@ exports.podium = require("./podium").default;
|
||||
exports.emsUpload = require("./emsUpload").default;
|
||||
exports.carfax = require("./carfax").default;
|
||||
exports.carfaxRps = require("./carfax-rps").default;
|
||||
exports.vehicletype = require("./vehicletype/vehicletype").default;
|
||||
exports.documentAnalytics = require("./analytics/documents").default;
|
||||
exports.chatterApi = require("./chatter-api").default;
|
||||
|
||||
@@ -264,29 +264,30 @@ const CreateRepairOrderTag = (job, errorCallback) => {
|
||||
}${job.est_ct_fn ? job.est_ct_fn : ""}`
|
||||
},
|
||||
Dates: {
|
||||
DateEstimated: (job.date_estimated && moment(job.date_estimated).format(DateFormat)) || "",
|
||||
DateOpened: (job.date_opened && moment(job.date_opened).format(DateFormat)) || "",
|
||||
DateScheduled:
|
||||
(job.scheduled_in && moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateArrived: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateEstimated: job.date_estimated ? moment(job.date_estimated).format(DateFormat) : "",
|
||||
DateOpened: job.date_open ? moment(job.date_open).tz(job.bodyshop.timezone).format(DateFormat) : "",
|
||||
DateScheduled: job.scheduled_in ? moment(job.scheduled_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
|
||||
DateArrived: job.actual_in ? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat) : "",
|
||||
DateStart: job.date_repairstarted
|
||||
? (job.date_repairstarted && moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)) ||
|
||||
""
|
||||
: (job.actual_in && moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateScheduledCompletion:
|
||||
(job.scheduled_completion && moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)) ||
|
||||
"",
|
||||
DateCompleted:
|
||||
(job.actual_completion && moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateScheduledDelivery:
|
||||
(job.scheduled_delivery && moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateDelivered:
|
||||
(job.actual_delivery && moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateInvoiced:
|
||||
(job.date_invoiced && moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateExported:
|
||||
(job.date_exported && moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat)) || "",
|
||||
DateVoid: (job.date_void && moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat)) || ""
|
||||
? moment(job.date_repairstarted).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: job.actual_in
|
||||
? moment(job.actual_in).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: "",
|
||||
DateScheduledCompletion: job.scheduled_completion
|
||||
? moment(job.scheduled_completion).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: "",
|
||||
DateCompleted: job.actual_completion
|
||||
? moment(job.actual_completion).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: "",
|
||||
DateScheduledDelivery: job.scheduled_delivery
|
||||
? moment(job.scheduled_delivery).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: "",
|
||||
DateDelivered: job.actual_delivery
|
||||
? moment(job.actual_delivery).tz(job.bodyshop.timezone).format(DateFormat)
|
||||
: "",
|
||||
DateInvoiced: job.date_invoiced ? moment(job.date_invoiced).tz(job.bodyshop.timezone).format(DateFormat) : "",
|
||||
DateExported: job.date_exported ? moment(job.date_exported).tz(job.bodyshop.timezone).format(DateFormat) : "",
|
||||
DateVoid: job.date_void ? moment(job.date_void).tz(job.bodyshop.timezone).format(DateFormat) : ""
|
||||
},
|
||||
JobLineDetails: (function () {
|
||||
const joblineSource = Array.isArray(job.joblines) ? job.joblines : job.joblines ? [job.joblines] : [];
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
[
|
||||
"PROMASTER 1500",
|
||||
"PROMASTER 2500",
|
||||
"PROMASTER CITY",
|
||||
"NV 1500",
|
||||
"NV 200",
|
||||
"NV 2500",
|
||||
"NV 3500",
|
||||
"NV1500",
|
||||
"NV200",
|
||||
"NV2500",
|
||||
"NV3500",
|
||||
"SPRINTER",
|
||||
"E150 ECONOLINE CARGO VAN",
|
||||
"E150 ECONOLINE XL",
|
||||
"E250 ECONOLINE CARGO",
|
||||
"E250 ECONOLINE CARGO (AMALGAM)",
|
||||
"E250 ECONOLINE CARGO (INSPECT)",
|
||||
"E250 ECONOLINE CARGO VAN EXT",
|
||||
"E250 ECONOLINE SUPER CARGO VAN",
|
||||
"E350 CUTAWAY VAN",
|
||||
"E350 ECONO SD CARGO VAN EXT",
|
||||
"E350 ECONOLINE CARGO VAN",
|
||||
"E350 ECONOLINE CUTAWAY",
|
||||
"E350 ECONOLINE SD CARGO VAN",
|
||||
"E350 ECONOLINE SD XL",
|
||||
"E350 ECONOLINE SD XL EXT",
|
||||
"E350 ECONOLINE SD XLT",
|
||||
"E350 ECONOLINE SD XLT EXT",
|
||||
"E350 SD CUTAWAY",
|
||||
"E450",
|
||||
"E450 ECONOLINE",
|
||||
"E450 ECONOLINE SD",
|
||||
"E450 ECONOLINE SD CUTAWAY",
|
||||
"TRANSIT 150 WB 130 CARGO VAN",
|
||||
"TRANSIT 150 WB 130 XLT",
|
||||
"TRANSIT 150 WB 148 CARGO VAN",
|
||||
"TRANSIT 250 WB 130 CARGO VAN",
|
||||
"TRANSIT 250 WB 148 CARGO VAN",
|
||||
"TRANSIT 250 WB 148 EL CARGO",
|
||||
"TRANSIT 350 WB 148 CARGO VAN",
|
||||
"TRANSIT 350 WB 148 EL CARGO",
|
||||
"TRANSIT CONNECT XL CARGO VAN",
|
||||
"TRANSIT CONNECT XLT CARGO VAN",
|
||||
"250 TRANSIT",
|
||||
"CITY EXPRESS LS CARGO VAN",
|
||||
"CITY EXPRESS LT CARGO VAN",
|
||||
"EXPRESS 1500",
|
||||
"EXPRESS 1500 CARGO VAN",
|
||||
"EXPRESS 1500 LS",
|
||||
"EXPRESS 1500 LT",
|
||||
"EXPRESS 2500 CARGO VAN",
|
||||
"EXPRESS 2500 CARGO VAN EXT",
|
||||
"EXPRESS 2500 LS",
|
||||
"EXPRESS 2500 LT",
|
||||
"EXPRESS 3500",
|
||||
"EXPRESS 3500 CARGO VAN",
|
||||
"EXPRESS 3500 CARGO VAN EXT",
|
||||
"EXPRESS 3500 EXT",
|
||||
"EXPRESS 3500 LS",
|
||||
"EXPRESS 3500 LS EXT",
|
||||
"EXPRESS 3500 LT",
|
||||
"EXPRESS 3500 LT EXT",
|
||||
"G3500 EXPRESS CUTAWAY",
|
||||
"SAVANA 1500 CARGO VAN",
|
||||
"SAVANA 1500 SL",
|
||||
"SAVANA 1500 SLE",
|
||||
"SAVANA 2500",
|
||||
"2500 SAVANA",
|
||||
"SAVANA 2500 CARGO VAN",
|
||||
"SAVANA 2500 CARGO VAN EXT",
|
||||
"SAVANA 2500 LT",
|
||||
"SAVANA 2500 SLE",
|
||||
"SAVANA 3500",
|
||||
"SAVANA 3500 CARGO VAN",
|
||||
"SAVANA 3500 CARGO VAN EXT",
|
||||
"SAVANA 3500 EXT",
|
||||
"SAVANA 3500 LT EXT",
|
||||
"SAVANA 3500 SLE EXT",
|
||||
"SAVANA G3500 CUTAWAY",
|
||||
"SAVANA G4500 CUTAWAY",
|
||||
"EXPRESS 1500 LS CARGO VAN",
|
||||
"G20 SPORTVAN",
|
||||
"NV 3500 S V8 CARGO VAN",
|
||||
"E-150",
|
||||
"E-250",
|
||||
"E-350",
|
||||
"E-450",
|
||||
"E150",
|
||||
"E250",
|
||||
"E350",
|
||||
"TRANSIT",
|
||||
"CITY",
|
||||
"CITY EXPRESS",
|
||||
"EXPRESS",
|
||||
"EXPRESS 2500",
|
||||
"G3500",
|
||||
"SAVANA",
|
||||
"SAVANA 1500",
|
||||
"CHEVY EXPRESS G2500",
|
||||
"CLUBWAGON E350",
|
||||
"TRANSIT CONNECT",
|
||||
"SPRINTER 2500",
|
||||
"TRANSIT 150",
|
||||
"ECONOLINE E250",
|
||||
"TRANSIT 250",
|
||||
"ECONOLINE E350",
|
||||
"NV3500 HD",
|
||||
"TRANSIT 350HD",
|
||||
"ECONOLINE E150",
|
||||
"E250 ECONOLINE",
|
||||
"C/V",
|
||||
"E350 CHSCAB",
|
||||
"G1500 CHEVY EXPRESS",
|
||||
"2500 SPRINTER",
|
||||
"E150 ECONOLINE",
|
||||
"350 TRANSIT",
|
||||
"E450 CUTAWAY",
|
||||
"PROMASTER 3500",
|
||||
"CHEVY EXPRESS G3500",
|
||||
"SAVANA G3500",
|
||||
"1500 PROMASTER",
|
||||
"2500 EXPRESS",
|
||||
"3500 EXPRESS",
|
||||
"3500 SPRINTER"
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
[
|
||||
"GRAND CARAVAN",
|
||||
"GRANDCARAVAN",
|
||||
"GRAND CARAVAN CREW",
|
||||
"GRAND CARAVAN CV",
|
||||
"GRAND CARAVAN CVP",
|
||||
"GRAND CARAVAN SE",
|
||||
"GRAND CARAVAN SXT",
|
||||
"CARAVAN CV",
|
||||
"SIENNA CE V6",
|
||||
"SIENNA LE V6",
|
||||
"SIENNA XLE V6",
|
||||
"SIENNA",
|
||||
"ODYSSEY",
|
||||
"SEDONA",
|
||||
"PACIFICA (NEW)",
|
||||
"QUEST",
|
||||
"CARAVAN",
|
||||
"MONTANA SV6",
|
||||
"FREESTAR",
|
||||
"UPLANDER",
|
||||
"MONTANA",
|
||||
"VOYAGER",
|
||||
"ENTOURAGE",
|
||||
"PACIFICA",
|
||||
"CARNIVAL",
|
||||
"VENTURE",
|
||||
"SAFARI",
|
||||
"VANAGON",
|
||||
"WINDSTAR",
|
||||
"TOWN&COUNTRY",
|
||||
"ROUTAN"
|
||||
]
|
||||
@@ -1,485 +0,0 @@
|
||||
[
|
||||
"EDGE SEL",
|
||||
"ESCAPE",
|
||||
"ESCAPE SE",
|
||||
"ESCAPE SEL",
|
||||
"ESCAPE XLT V6",
|
||||
"EXPEDITION",
|
||||
"EXPEDITION LIMITED",
|
||||
"EXPEDITION MAX",
|
||||
"EXPEDITION MAX LIMITED",
|
||||
"EXPLORER",
|
||||
"EXCURSION",
|
||||
"EXPLORER LIMITED",
|
||||
"EXPLORER PLATINUM ECOBOOST",
|
||||
"EXPLORER XLT",
|
||||
"FLEX",
|
||||
"FLEX SE",
|
||||
"ECOSPORT",
|
||||
"ESCAPE HYBRID",
|
||||
"MUSTANG MACH-E",
|
||||
"BRONCO",
|
||||
"BRONCO SPORT",
|
||||
"TRAILBLAZER",
|
||||
"BLAZER LT",
|
||||
"CHEROKEE",
|
||||
"CHEROKEE CLASSIC",
|
||||
"CHEROKEE COUNTRY",
|
||||
"CHEROKEE LIMITED",
|
||||
"CHEROKEE NORTH",
|
||||
"CHEROKEE OVERLAND",
|
||||
"CHEROKEE SPORT",
|
||||
"CHEROKEE TRAILHAWK",
|
||||
"CJ",
|
||||
"CJ7",
|
||||
"CJ7 RENEGADE",
|
||||
"COMMANDER",
|
||||
"COMMANDER LIMITED",
|
||||
"COMMANDER SPORT",
|
||||
"COMPASS",
|
||||
"COMPASS HIGH ALTITUDE",
|
||||
"COMPASS LATITUDE",
|
||||
"COMPASS LIMITED",
|
||||
"COMPASS NORTH",
|
||||
"COMPASS SPORT",
|
||||
"COMPASS TRAILHAWK",
|
||||
"GLADIATOR OVERLAND",
|
||||
"GLADIATOR RUBICON",
|
||||
"GRAND CHEROKEE LAREDO",
|
||||
"GRAND CHEROKEE LIMITED",
|
||||
"GRAND CHEROKEE OVERLAND",
|
||||
"GRAND CHEROKEE SE",
|
||||
"GRAND CHEROKEE SRT",
|
||||
"GRAND CHEROKEE SRT8",
|
||||
"GRAND CHEROKEE SUMMIT",
|
||||
"GRAND CHEROKEE TRACKHAWK",
|
||||
"GRAND CHEROKEE TRAILHAWK",
|
||||
"GRAND CHEROKEE",
|
||||
"GRANDCHEROKEE",
|
||||
"LIBERTY LIMITED",
|
||||
"LIBERTY RENEGADE",
|
||||
"LIBERTY SPORT",
|
||||
"LIBERTY",
|
||||
"PATRIOT",
|
||||
"PATRIOT HIGH ALTITUDE",
|
||||
"PATRIOT LATITUDE",
|
||||
"PATRIOT LIMITED",
|
||||
"PATRIOT NORTH",
|
||||
"PATRIOT SPORT",
|
||||
"RENEGADE LIMITED",
|
||||
"RENEGADE NORTH",
|
||||
"RENEGADE SPORT",
|
||||
"RENEGADE TRAILHAWK",
|
||||
"TJ",
|
||||
"TJ RUBICON",
|
||||
"TJ SAHARA",
|
||||
"TJ SPORT",
|
||||
"TJ UNLIMITED",
|
||||
"WRANGLER",
|
||||
"WRANGLER RUBICON",
|
||||
"WRANGLER SAHARA",
|
||||
"WRANGLER SPORT",
|
||||
"WRANGLER UNLIMITED",
|
||||
"WRANGLER UNLIMITED 70TH ANNIV",
|
||||
"WRANGLER UNLIMITED RUBICON",
|
||||
"WRANGLER UNLIMITED SAHARA",
|
||||
"WRANGLER UNLIMITED SPORT",
|
||||
"WRANGLER UNLIMITED X",
|
||||
"WRANGLER X",
|
||||
"YJ WRANGLER",
|
||||
"AVIATOR",
|
||||
"AVIATOR RESERVE",
|
||||
"MKC",
|
||||
"MKC RESERVE",
|
||||
"MKC SELECT",
|
||||
"MKT",
|
||||
"MKT ECOBOOST",
|
||||
"MKX",
|
||||
"MKX RESERVE",
|
||||
"NAUTILUS RESERVE",
|
||||
"NAUTILUS RESERVE V6",
|
||||
"NAVIGATOR",
|
||||
"NAVIGATOR L",
|
||||
"NAVIGATOR L RESERVE",
|
||||
"NAVIGATOR L SELECT",
|
||||
"NAVIGATOR RESERVE",
|
||||
"PILOT",
|
||||
"PILOT BLACK EDITION",
|
||||
"PILOT ELITE",
|
||||
"PILOT EX",
|
||||
"PILOT EX-L",
|
||||
"PILOT GRANITE",
|
||||
"PILOT LX",
|
||||
"PILOT SE",
|
||||
"PILOT SE-L",
|
||||
"PILOT TOURING",
|
||||
"DURANGO R/T",
|
||||
"DURANGO SLT PLUS",
|
||||
"DURANGO SRT",
|
||||
"DURANGO",
|
||||
"JOURNEY",
|
||||
"JOURNEY CROSSROAD",
|
||||
"JOURNEY CVP",
|
||||
"JOURNEY LIMITED",
|
||||
"JOURNEY R/T",
|
||||
"JOURNEY SXT",
|
||||
"NITRO SE",
|
||||
"NITRO",
|
||||
"K1500 SUBURBAN",
|
||||
"SUBURBAN 1500 LT",
|
||||
"SUBURBAN 1500 LTZ",
|
||||
"SUBURBAN 1500 PREMIER",
|
||||
"SUBURBAN 2500 LS",
|
||||
"TAHOE LT",
|
||||
"TRAVERSE LS",
|
||||
"TRAVERSE LT",
|
||||
"TRAVERSE PREMIER",
|
||||
"TRAX LT",
|
||||
"TRAX PREMIER",
|
||||
"UPLANDER LT EXT",
|
||||
"SUBURBAN",
|
||||
"TAHOE",
|
||||
"TRAVERSE",
|
||||
"TRAX",
|
||||
"UPLANDER",
|
||||
"YUKON",
|
||||
"YUKON DENALI",
|
||||
"YUKON XL",
|
||||
"YUKON XL DENALI",
|
||||
"EQUINOX LS",
|
||||
"EQUINOX LT",
|
||||
"EQUINOX PREMIER",
|
||||
"EQUINOX",
|
||||
"RAV4 LE",
|
||||
"RAV4 XLE",
|
||||
"HIGHLANDER SPORT V6",
|
||||
"4RUNNER SR5 V6",
|
||||
"RAV4",
|
||||
"RAV4 HYBRID",
|
||||
"RAV4 XLE HYBRID",
|
||||
"HIGHLANDER",
|
||||
"4RUNNER",
|
||||
"SEQUOIA",
|
||||
"PATHFINDER SE",
|
||||
"PATHFINDER SL",
|
||||
"PATHFINDER",
|
||||
"MURANO PLATINUM",
|
||||
"MURANO SV",
|
||||
"MURANO",
|
||||
"TUCSON",
|
||||
"TERRAIN",
|
||||
"SORENTO",
|
||||
"EDGE",
|
||||
"KICKS",
|
||||
"QASHQAI",
|
||||
"SANTA FE",
|
||||
"ARMADA",
|
||||
"TELLURIDE",
|
||||
"PALISADE",
|
||||
"SELTOS",
|
||||
"TORRENT",
|
||||
"C-HR",
|
||||
"SPORTAGE",
|
||||
"VENZA",
|
||||
"ACADIA",
|
||||
"CR-V",
|
||||
"HR-V",
|
||||
"CX-5",
|
||||
"CX-50",
|
||||
"CX-7",
|
||||
"CX-9",
|
||||
"CX-3",
|
||||
"Q3",
|
||||
"Q5",
|
||||
"Q7",
|
||||
"Q8",
|
||||
"JUKE SV",
|
||||
"JUKE",
|
||||
"ROGUE",
|
||||
"ROGUE SV",
|
||||
"XTERRA",
|
||||
"COROLLA CROSS",
|
||||
"ACADIA DENALI",
|
||||
"TAURUS X",
|
||||
"MACAN",
|
||||
"FJ CRUISER",
|
||||
"BRONCO SPORT BADLANDS",
|
||||
"ESCALADE",
|
||||
"RX 350",
|
||||
"KONA",
|
||||
"MDX",
|
||||
"RDX",
|
||||
"COOPER COUNTRYMAN",
|
||||
"V70",
|
||||
"OUTLANDER",
|
||||
"RIO5",
|
||||
"GLC300 COUPE",
|
||||
"ENCORE",
|
||||
"SRX",
|
||||
"SANTA FE SPORT",
|
||||
"NX 300",
|
||||
"WRANGLER UNLIMITE",
|
||||
"WRANGLER JK UNLIM",
|
||||
"RANGEROVER EVOQUE",
|
||||
"CROSSTREK",
|
||||
"FORESTER",
|
||||
"TIGUAN",
|
||||
"XV CROSSTREK",
|
||||
"ENDEAVOR",
|
||||
"RX 330",
|
||||
"ATLAS",
|
||||
"XC90",
|
||||
"TOUAREG",
|
||||
"STELVIO",
|
||||
"RANGE ROVER SPORT",
|
||||
"GLE350D",
|
||||
"EX35",
|
||||
"RVR",
|
||||
"MONTERO",
|
||||
"X-TRAIL",
|
||||
"GRAND VITARA",
|
||||
"TRIBUTE",
|
||||
"X3",
|
||||
"XC60",
|
||||
"GLK250 BLUETEC",
|
||||
"ENVOY",
|
||||
"ML350 BLUETEC",
|
||||
"ENVISION",
|
||||
"FX35",
|
||||
"X1",
|
||||
"VENUE",
|
||||
"TAOS",
|
||||
"KONA ELECTRIC",
|
||||
"OUTLANDER PHEV",
|
||||
"PASSPORT",
|
||||
"H3",
|
||||
"EXPLORERSPORTTRAC",
|
||||
"F-PACE",
|
||||
"ML320 BLUETEC",
|
||||
"REGAL SPORTBACK",
|
||||
"DISCOVERY SPORT",
|
||||
"RENDEZVOUS",
|
||||
"XC70",
|
||||
"COMPASS (NEW)",
|
||||
"CUBE",
|
||||
"V60 CROSS COUNTRY",
|
||||
"QX70",
|
||||
"X6",
|
||||
"ELEMENT",
|
||||
"RX 400H",
|
||||
"VUE",
|
||||
"RANGE ROVER VELAR",
|
||||
"E-PACE",
|
||||
"RAV4 PRIME",
|
||||
"LX 570",
|
||||
"GX 470",
|
||||
"EX37",
|
||||
"GLE43",
|
||||
"NAUTILUS",
|
||||
"XT6",
|
||||
"RX 450H",
|
||||
"ESCALADE ESV",
|
||||
"OUTLOOK",
|
||||
"CAYENNE",
|
||||
"XC90 PLUG-IN",
|
||||
"MODEL X",
|
||||
"MODEL Y",
|
||||
"GLC300",
|
||||
"SANTA FE HYBRID",
|
||||
"G63",
|
||||
"XV CROSSTREK HYBR",
|
||||
"JX35",
|
||||
"JIMMY",
|
||||
"TUCSON HYBRID",
|
||||
"XC40 ELECTRIC",
|
||||
"RX 300",
|
||||
"ML320",
|
||||
"WRANGLER JK UNLIMITED",
|
||||
"POLICE INTERCEPTOR UTILITY",
|
||||
"WRANGLER JK",
|
||||
"TRIBECA",
|
||||
"E-TRON SPORTBACK",
|
||||
"500X",
|
||||
"RX 350H",
|
||||
"GL350 BLUETEC",
|
||||
"WRANGLER UNLIMITED 4XE",
|
||||
"GV80",
|
||||
"GL550",
|
||||
"Q5 E",
|
||||
"H2 SUV",
|
||||
"Q5 HYBRID",
|
||||
"IONIQ 5",
|
||||
"SQ5 SPORTBACK",
|
||||
"LEVANTE",
|
||||
"TONALE",
|
||||
"GLE43 COUPE",
|
||||
"GRAND CHEROKEE WK",
|
||||
"DEFENDER",
|
||||
"NX 450H+",
|
||||
"ML400",
|
||||
"LX 600",
|
||||
"RX 450HL",
|
||||
"SORENTO HYBRID",
|
||||
"NX 350",
|
||||
"TRACKER",
|
||||
"GLE450",
|
||||
"Q5 SPORTBACK",
|
||||
"CR-V HYBRID",
|
||||
"LX 470",
|
||||
"EQS580 SUV",
|
||||
"H2",
|
||||
"EV9",
|
||||
"SORENTO PLUG-IN",
|
||||
"LYRIQ",
|
||||
"GLE550",
|
||||
"RX 500H",
|
||||
"X1 SAV",
|
||||
"E-TRON S SPORTBACK",
|
||||
"ML500",
|
||||
"GRAND HIGHLANDER HYBRID",
|
||||
"RS Q8",
|
||||
"GLS550",
|
||||
"GLS580",
|
||||
"IX",
|
||||
"CAYENNE COUPE",
|
||||
"SOLTERRA",
|
||||
"PATHFINDER HYBRID",
|
||||
"Q8 E-TRON",
|
||||
"TX 350",
|
||||
"TX 500H",
|
||||
"EQUINOX EV",
|
||||
"NAUTILUS HYBRID",
|
||||
"TRAVERSE LIMITED",
|
||||
"CX-70",
|
||||
"SANTA FE XL",
|
||||
"RENEGADE",
|
||||
"QX50",
|
||||
"ECLIPSE CROSS",
|
||||
"QX80",
|
||||
"X5",
|
||||
"X3",
|
||||
"X1",
|
||||
"X4",
|
||||
"ENCLAVE",
|
||||
"ENCORE GX",
|
||||
"CAYENNE HYBRID",
|
||||
"SOUL",
|
||||
"GX 460",
|
||||
"UX 250H",
|
||||
"XT5",
|
||||
"GLE53",
|
||||
"XT4",
|
||||
"SQ7",
|
||||
"NX 350H",
|
||||
"GLK350",
|
||||
"GLE350",
|
||||
"NX 300H",
|
||||
"NX 200T",
|
||||
"RANGE ROVER EVOQUE",
|
||||
"GLS450",
|
||||
"TERRAIN DENALI",
|
||||
"GRAND CHEROKEE L",
|
||||
"GLE400",
|
||||
"TUCSON PLUG-IN",
|
||||
"BLAZER",
|
||||
"ASCENT",
|
||||
"HIGHLANDER HYBRID",
|
||||
"ATLAS CROSS SPORT",
|
||||
"XC40",
|
||||
"VENZA HYBRID",
|
||||
"GLA45",
|
||||
"GLB250",
|
||||
"GRAND HIGHLANDER",
|
||||
"GV70",
|
||||
"NIRO",
|
||||
"NIRO EV",
|
||||
"GLA250",
|
||||
"ESCAPE PLUG-IN",
|
||||
"WAGONEER",
|
||||
"CX-30",
|
||||
"QX60",
|
||||
"GRAND CHEROKEE 4XE",
|
||||
"SPORTAGE HYBRID",
|
||||
"EV6",
|
||||
"TONALE PLUG-IN",
|
||||
"GLC43 COUPE",
|
||||
"X2",
|
||||
"RX 350L",
|
||||
"HORNET",
|
||||
"ENVISTA",
|
||||
"LEVANTE S",
|
||||
"SPORTAGE PLUG-IN",
|
||||
"ORLANDO",
|
||||
"X5 M",
|
||||
"EXPLORER HYBRID",
|
||||
"FREESTYLE",
|
||||
"CORSAIR",
|
||||
"K1500 YUKON XL",
|
||||
"RANGE ROVER",
|
||||
"SUV W/O LABOR",
|
||||
"ID.4",
|
||||
"CX-90",
|
||||
"X7",
|
||||
"CORSAIR PLUG-IN",
|
||||
"ESCALADE EXT",
|
||||
"QX55",
|
||||
"DISCOVERY",
|
||||
"BOLT EUV",
|
||||
"C40 ELECTRIC",
|
||||
"LR4",
|
||||
"GRAND WAGONEER",
|
||||
"XC60 PLUG-IN",
|
||||
"LR2",
|
||||
"EQE350 SUV",
|
||||
"COROLLA CROSS HYBRID",
|
||||
"SOUL EV",
|
||||
"GRECALE",
|
||||
"SUV W/O LABOR",
|
||||
"QX30",
|
||||
"SQ5",
|
||||
"NIRO PLUG-IN",
|
||||
"BORREGO",
|
||||
"CX-90 PLUG-IN",
|
||||
"XL-7",
|
||||
"SUV W/O LABOR",
|
||||
"SUV W/O LABOR",
|
||||
"I-PACE",
|
||||
"HORNET PLUG-IN",
|
||||
"UX 300H",
|
||||
"ML320 CDI",
|
||||
"VERACRUZ",
|
||||
"SQ8",
|
||||
"GLE53 COUPE",
|
||||
"ZDX",
|
||||
"9-7X",
|
||||
"ARIYA",
|
||||
"ASPEN",
|
||||
"AVIATOR PLUG-IN",
|
||||
"B9 TRIBECA",
|
||||
"BRAVADA",
|
||||
"ENVOY XL",
|
||||
"EQB350",
|
||||
"EQB350 SUV",
|
||||
"ESCALADE-V",
|
||||
"E-TRON",
|
||||
"FX37",
|
||||
"GL320 CDI",
|
||||
"GLADIATOR",
|
||||
"GLC43",
|
||||
"GLE450 COUPE",
|
||||
"GLE63",
|
||||
"GV60",
|
||||
"MKT TOWN CAR",
|
||||
"ML350",
|
||||
"ML550",
|
||||
"ML63",
|
||||
"NX 250",
|
||||
"Q4 E-TRON",
|
||||
"Q8 E-TRON SPORTBACK",
|
||||
"QX4",
|
||||
"QX56",
|
||||
"SANTA FE PLUG-IN",
|
||||
"UX 200",
|
||||
"WAGONEER L",
|
||||
"XB"
|
||||
]
|
||||
@@ -1,567 +0,0 @@
|
||||
[
|
||||
"MARK LT",
|
||||
|
||||
"F-150",
|
||||
"F-250",
|
||||
"F-350",
|
||||
"F-450",
|
||||
"F-550",
|
||||
"F-650",
|
||||
"F100 PICKUP",
|
||||
"F150 FX2 SUPERCAB",
|
||||
"F150 FX4 PICKUP",
|
||||
"F150 FX4 SUPERCAB",
|
||||
"F150 FX4 SUPERCREW",
|
||||
"F150 HARLEY DAVIDSON SUPERCAB",
|
||||
"F150 HARLEY DAVIDSON SUPERCREW",
|
||||
"F150 KING RANCH SUPERCREW",
|
||||
"F150 LARIAT FX4 SUPERCREW",
|
||||
"F150 LARIAT HARLEY DAVIDSON SC",
|
||||
"F150 LARIAT KING RANCH SUPCREW",
|
||||
"F150 LARIAT LIMITED SUPERCREW",
|
||||
"F150 LARIAT PICKUP",
|
||||
"F150 LARIAT SUPERCAB",
|
||||
"F150 LARIAT SUPERCAB (AMALGAM)",
|
||||
"F150 LARIAT SUPERCREW",
|
||||
"F150 LARIAT SUPERCREW (AMALGA)",
|
||||
"F150 LIMITED SUPERCREW",
|
||||
"F150 PICKUP",
|
||||
"F150 PLATINUM SUPERCREW",
|
||||
"F150 RAPTOR SUPERCAB",
|
||||
"F150 RAPTOR SUPERCREW",
|
||||
"F150 STX PICKUP",
|
||||
"F150 STX SUPERCAB",
|
||||
"F150 SUPERCAB",
|
||||
"F150 SUPERCREW",
|
||||
"F150 SUPERCREW (AMALGAMATED)",
|
||||
"F150 SVT RAPTOR SUPERCAB",
|
||||
"F150 XL PICKUP",
|
||||
"F150 XL SUPERCAB",
|
||||
"F150 XL SUPERCREW",
|
||||
"F150 XLT LARIAT SUPERCAB",
|
||||
"F150 XLT PICKUP",
|
||||
"F150 XLT SUPERCAB",
|
||||
"F150 XLT SUPERCREW",
|
||||
"F150 XLT SUPERCREW (AMALGAMAT)",
|
||||
"F150 XTR SUPERCAB",
|
||||
"F250 PICKUP",
|
||||
"F250 SD CREW CAB",
|
||||
"F250 SD FX4 CREW CAB",
|
||||
"F250 SD FX4 SUPERCAB",
|
||||
"F250 SD KING RANCH CREW CAB",
|
||||
"F250 SD LARIAT CREW CAB",
|
||||
"F250 SD LARIAT CREW CAB (AMAL)",
|
||||
"F250 SD LARIAT PICKUP",
|
||||
"F250 SD LARIAT SUPERCAB",
|
||||
"F250 SD LIMITED CREW CAB",
|
||||
"F250 SD PLATINUM CREW CAB",
|
||||
"F250 SD SUPERCAB",
|
||||
"F250 SD XL CREW CAB",
|
||||
"F250 SD XL PICKUP",
|
||||
"F250 SD XL SUPERCAB",
|
||||
"F250 SD XLT CREW CAB",
|
||||
"F250 SD XLT PICKUP",
|
||||
"F250 SD XLT SUPERCAB",
|
||||
"F250 SUPERCAB",
|
||||
"F250 XL CREW CAB",
|
||||
"F350 CREW CAB",
|
||||
"F350 PICKUP",
|
||||
"F350 PICKUP 2WD",
|
||||
"F350 SD CABELAS CREW CAB",
|
||||
"F350 SD CREW CAB",
|
||||
"F350 SD FX4 CREW CAB",
|
||||
"F350 SD FX4 SUPERCAB",
|
||||
"F350 SD HARLEY DAVIDSON",
|
||||
"F350 SD KING RANCH CREW CAB",
|
||||
"F350 SD LARIAT CREW CAB",
|
||||
"F350 SD LARIAT CREW CAB (AMAL)",
|
||||
"F350 SD LARIAT KING RANCH",
|
||||
"F350 SD LARIAT SUPERCAB",
|
||||
"F350 SD LIMITED CREW CAB",
|
||||
"F350 SD PICKUP",
|
||||
"F350 SD PLATINUM CREW CAB",
|
||||
"F350 SD SUPERCAB",
|
||||
"F350 SD XL CREW CAB",
|
||||
"F350 SD XL PICKUP",
|
||||
"F350 SD XL SUPERCAB",
|
||||
"F350 SD XLT CREW CAB",
|
||||
"F350 SD XLT SUPERCAB",
|
||||
"F350 SUPER DUTY",
|
||||
"F350 SUPER DUTY XL",
|
||||
"F350 XL PICKUP",
|
||||
"F450",
|
||||
"F450 Pickup",
|
||||
"F450 SD KING RANCH CREW CAB",
|
||||
"F450 SD LARIAT CREW CAB",
|
||||
"F450 SD PICKUP",
|
||||
"F450 SD PLATINUM CREW CAB",
|
||||
"F450 SD XL",
|
||||
"F450 SD XL CREW CAB",
|
||||
"F450 SD XL PICKUP",
|
||||
"F450 SD XLT CREW CAB",
|
||||
"F450 SUPER DUTY XLT",
|
||||
"F550",
|
||||
"F550 SD",
|
||||
"F550 SD XL",
|
||||
"F550 SD XL PICKUP",
|
||||
"F550 SD XLT CREW CAB",
|
||||
"F550 SD XLT SUPERCAB",
|
||||
"F550 SUPER DUTY",
|
||||
"F550 SUPER DUTY XL",
|
||||
"F550 SUPER DUTY XLT",
|
||||
"F550 SUPER DUTY XLT CREW CAB",
|
||||
"F550 XL",
|
||||
"F650 SD XLT SUPERCAB",
|
||||
"F68",
|
||||
"F750 XL",
|
||||
|
||||
"RANGER",
|
||||
"RANGER EDGE SUPERCAB",
|
||||
"RANGER FX4 SUPERCAB",
|
||||
"RANGER LARIAT SUPERCREW",
|
||||
"RANGER SPORT SUPERCAB",
|
||||
"RANGER STX SUPERCAB",
|
||||
"RANGER SUPERCAB",
|
||||
"RANGER XL",
|
||||
"RANGER XL SUPERCAB",
|
||||
"RANGER XLT",
|
||||
"RANGER XLT SUPERCAB",
|
||||
"RANGER XLT SUPERCREW",
|
||||
|
||||
"FRONTIER LE CREW CAB V6",
|
||||
"FRONTIER NISMO CREW CAB V6",
|
||||
"FRONTIER NISMO KING CAB V6",
|
||||
"FRONTIER PRO-4X CREW CAB V6",
|
||||
"FRONTIER PRO-4X KING CAB V6",
|
||||
"FRONTIER S KING CAB",
|
||||
"FRONTIER SC CREW CAB V6",
|
||||
"FRONTIER SC V6",
|
||||
"FRONTIER SE CREW CAB V6",
|
||||
"FRONTIER SE KING CAB V6",
|
||||
"FRONTIER SL CREW CAB V6",
|
||||
"FRONTIER SV CREW CAB V6",
|
||||
"FRONTIER SV KING CAB V6",
|
||||
"FRONTIER XE KING CAB",
|
||||
"FRONTIER XE KING CAB V6",
|
||||
"KING CAB",
|
||||
|
||||
"TITAN 5.6 LE CREW CAB",
|
||||
"TITAN 5.6 LE KING CAB",
|
||||
"TITAN 5.6 MIDNIGHT CREW CAB",
|
||||
"TITAN 5.6 PLATINUM RESERVE CC",
|
||||
"TITAN 5.6 PRO-4X CREW CAB",
|
||||
"TITAN 5.6 PRO-4X KING CAB",
|
||||
"TITAN 5.6 S CREW CAB",
|
||||
"TITAN 5.6 SE CREW CAB",
|
||||
"TITAN 5.6 SE KING CAB",
|
||||
"TITAN 5.6 SL CREW CAB",
|
||||
"TITAN 5.6 SV CREW CAB",
|
||||
"TITAN 5.6 SV KING CAB",
|
||||
"TITAN 5.6 XE CREW CAB",
|
||||
"TITAN 5.6 XE KING CAB",
|
||||
"TITAN XD PLATINUM CREW CAB",
|
||||
"TITAN XD PRO-4X CREW CAB",
|
||||
"TITAN XD S CREW CAB",
|
||||
"TITAN XD SL CREW CAB",
|
||||
"TITAN XD SV CREW CAB",
|
||||
|
||||
"PICKUP SR5",
|
||||
|
||||
"TACOMA",
|
||||
"TACOMA ACCESS CAB",
|
||||
"TACOMA DOUBLE CAB V6",
|
||||
"TACOMA LIMITED DOUBLE CAB V6",
|
||||
"TACOMA PRERUNNER DOUBLE CAB V6",
|
||||
"TACOMA PRERUNNER V6 ACCESS CAB",
|
||||
"TACOMA PRERUNNER XTRACAB",
|
||||
"TACOMA PRERUNNER XTRACAB V6",
|
||||
"TACOMA SR5 DOUBLE CAB V6",
|
||||
"TACOMA SR5 V6 ACCESS CAB",
|
||||
"TACOMA SR5 V6 XTRACAB",
|
||||
"TACOMA V6 ACCESS CAB",
|
||||
"TACOMA XTRACAB",
|
||||
"TACOMA XTRACAB V6",
|
||||
"TUNDRA ACCESS CAB V8",
|
||||
"TUNDRA DOUBLE CAB V8",
|
||||
"TUNDRA LIMITED ACCESS CAB V8",
|
||||
"TUNDRA LIMITED SR5 DBLCAB V8",
|
||||
"TUNDRA LIMITED V8",
|
||||
"TUNDRA LIMITED V8 CREWMAX",
|
||||
"TUNDRA LIMITED V8 DOUBLE CAB",
|
||||
"TUNDRA PLATINUM V8 CREWMAX",
|
||||
"TUNDRA SR DOUBLE CAB V8",
|
||||
"TUNDRA SR V8",
|
||||
"TUNDRA SR5 DOUBLE CAB V8",
|
||||
"TUNDRA SR5 TRD DOUBLE CAB V8",
|
||||
"TUNDRA SR5 V8 CREWMAX",
|
||||
"TUNDRA V8",
|
||||
"TUNDRA V8 CREWMAX",
|
||||
"XTRACAB LONG BOX",
|
||||
|
||||
"AVALANCHE 1500",
|
||||
"AVALANCHE 1500 LS",
|
||||
"AVALANCHE 1500 LS Z71",
|
||||
"AVALANCHE 1500 LT",
|
||||
"AVALANCHE 1500 LT Z71",
|
||||
"AVALANCHE 1500 LTZ",
|
||||
"C/R 10/1500 4+CAB",
|
||||
"C/R 10/1500 PICKUP",
|
||||
"C/R 20/2500 4+CAB",
|
||||
"C/R 20/2500 PICKUP",
|
||||
"C3500",
|
||||
|
||||
"COLORADO",
|
||||
"COLORADO EXT CAB",
|
||||
"COLORADO LS",
|
||||
"COLORADO LS CREW CAB",
|
||||
"COLORADO LS EXT CAB",
|
||||
"COLORADO LT",
|
||||
"COLORADO LT CREW CAB",
|
||||
"COLORADO LT EXT CAB",
|
||||
"COLORADO WT CREW CAB",
|
||||
"COLORADO WT EXT CAB",
|
||||
"COLORADO Z71 CREW CAB",
|
||||
"COLORADO Z71 EXT CAB",
|
||||
"COLORADO ZR2 CREW CAB",
|
||||
"COLORADO ZR2 EXT CAB",
|
||||
|
||||
"HHR LS PANEL",
|
||||
"K/V 10/1500 4+CAB",
|
||||
"K/V 10/1500 PICKUP",
|
||||
"K/V 20/2500 4+CAB",
|
||||
"K/V 20/2500 PICKUP",
|
||||
"K/V 30/3500 4+CAB",
|
||||
"Pickup K3500",
|
||||
"Pickup Silverado C2500 HD",
|
||||
"S10 4+CAB",
|
||||
"S10 LS 4+CAB",
|
||||
"SILVERADO 1500",
|
||||
"SILVERADO 1500 CHEYENNE CREW",
|
||||
"SILVERADO 1500 CREW CAB",
|
||||
"SILVERADO 1500 CREW CAB (AMAL)",
|
||||
"SILVERADO 1500 CUST TRAIL DC",
|
||||
"SILVERADO 1500 CUSTOM CREW CAB",
|
||||
"SILVERADO 1500 CUSTOM DC",
|
||||
"SILVERADO 1500 CUSTOM TRAIL CC",
|
||||
"SILVERADO 1500 DOUBLE (AMALGA)",
|
||||
"SILVERADO 1500 EXT CAB",
|
||||
"SILVERADO 1500 HD LS CREW CAB",
|
||||
"SILVERADO 1500 HD LT CREW CAB",
|
||||
"SILVERADO 1500 HIGH COUNTRY CC",
|
||||
"SILVERADO 1500 HYBRID CREW CAB",
|
||||
"SILVERADO 1500 LS",
|
||||
"SILVERADO 1500 LS CREW CAB",
|
||||
"SILVERADO 1500 LS DOUBLE CAB",
|
||||
"SILVERADO 1500 LS EXT CAB",
|
||||
"SILVERADO 1500 LT",
|
||||
"SILVERADO 1500 LT CC (AMALGAM)",
|
||||
"SILVERADO 1500 LT CREW CAB",
|
||||
"SILVERADO 1500 LT DOUBLE CAB",
|
||||
"SILVERADO 1500 LT EXT CAB",
|
||||
"SILVERADO 1500 LT TRAIL CC",
|
||||
"SILVERADO 1500 LT TRAIL DC",
|
||||
"SILVERADO 1500 LTZ CREW CAB",
|
||||
"SILVERADO 1500 LTZ DOUBLE CAB",
|
||||
"SILVERADO 1500 LTZ EXT CAB",
|
||||
"SILVERADO 1500 RST CREW CAB",
|
||||
"SILVERADO 1500 RST DOUBLE CAB",
|
||||
"SILVERADO 1500 SS EXT CAB",
|
||||
"SILVERADO 1500 WT",
|
||||
"SILVERADO 1500 WT CREW CAB",
|
||||
"SILVERADO 1500 WT DOUBLE CAB",
|
||||
"SILVERADO 1500 WT EXT CAB",
|
||||
"SILVERADO 2500 EXT CAB",
|
||||
"SILVERADO 2500 HD",
|
||||
"SILVERADO 2500 HD CREW CAB",
|
||||
"SILVERADO 2500 HD EXT CAB",
|
||||
"SILVERADO 2500 HD HC CREW CAB",
|
||||
"SILVERADO 2500 HD LS CREW CAB",
|
||||
"SILVERADO 2500 HD LS EXT CAB",
|
||||
"SILVERADO 2500 HD LT",
|
||||
"SILVERADO 2500 HD LT CREW CAB",
|
||||
"SILVERADO 2500 HD LT DBL CAB",
|
||||
"SILVERADO 2500 HD LT EXT CAB",
|
||||
"SILVERADO 2500 HD LTZ CREW CAB",
|
||||
"SILVERADO 2500 HD LTZ DBL CAB",
|
||||
"SILVERADO 2500 HD LTZ EXT CAB",
|
||||
"SILVERADO 2500 HD WT",
|
||||
"SILVERADO 2500 HD WT CREW CAB",
|
||||
"SILVERADO 2500 HD WT DBL CAB",
|
||||
"SILVERADO 2500 HD WT EXT CAB",
|
||||
"SILVERADO 3500",
|
||||
"SILVERADO 3500 CREW CAB",
|
||||
"SILVERADO 3500 CREW CAB (AMAL)",
|
||||
"SILVERADO 3500 EXT CAB",
|
||||
"SILVERADO 3500 HC CREW CAB",
|
||||
"SILVERADO 3500 HD (AMALGAMATE)",
|
||||
"SILVERADO 3500 LS",
|
||||
"SILVERADO 3500 LS CREW CAB",
|
||||
"SILVERADO 3500 LS EXT CAB",
|
||||
"SILVERADO 3500 LT CREW CAB",
|
||||
"SILVERADO 3500 LT DOUBLE CAB",
|
||||
"SILVERADO 3500 LT EXT CAB",
|
||||
"SILVERADO 3500 LTZ CREW CAB",
|
||||
"SILVERADO 3500 LTZ EXT CAB",
|
||||
"SILVERADO 3500 WT CREW CAB",
|
||||
"Silverado 3500HD",
|
||||
|
||||
"B250 SPORTSMAN",
|
||||
|
||||
"DAKOTA CLUB CAB",
|
||||
"DAKOTA LARAMIE V8 CLUB CAB",
|
||||
"DAKOTA LARAMIE V8 QUAD CAB",
|
||||
"DAKOTA QUAD CAB",
|
||||
"DAKOTA SLT CREW CAB",
|
||||
"DAKOTA SLT EXT CAB",
|
||||
"DAKOTA SLT PLUS QUAD CAB",
|
||||
"DAKOTA SLT PLUS V8 CLUB CAB",
|
||||
"DAKOTA SLT PLUS V8 QUAD CAB",
|
||||
"DAKOTA SLT QUAD CAB",
|
||||
"DAKOTA SLT V8 CLUB CAB",
|
||||
"DAKOTA SLT V8 CREW CAB",
|
||||
"DAKOTA SLT V8 EXT CAB",
|
||||
"DAKOTA SLT V8 QUAD CAB",
|
||||
"DAKOTA SPORT V8",
|
||||
"DAKOTA SPORT V8 CLUB CAB",
|
||||
"DAKOTA SPORT V8 QUAD CAB",
|
||||
"DAKOTA ST CLUB CAB",
|
||||
"DAKOTA ST QUAD CAB",
|
||||
"DAKOTA ST V8 QUAD CAB",
|
||||
"DAKOTA SXT CREW CAB",
|
||||
"DAKOTA SXT EXT CAB",
|
||||
"DAKOTA SXT V8 CREW CAB",
|
||||
"DAKOTA SXT V8 EXT CAB",
|
||||
"DAKOTA V8 CLUB CAB",
|
||||
"DAKOTA V8 QUAD CAB",
|
||||
|
||||
"RAM 1500",
|
||||
"RAM 1500 BIG HORN CREW CAB",
|
||||
"RAM 1500 BIG HORN QUAD CAB",
|
||||
"RAM 1500 CLUB CAB",
|
||||
"RAM 1500 CREW CAB (AMALGAMATE)",
|
||||
"RAM 1500 EXPRESS",
|
||||
"RAM 1500 LARAMIE CREW (AMALGA)",
|
||||
"RAM 1500 LARAMIE CREW CAB",
|
||||
"RAM 1500 LARAMIE LONGHORN CREW",
|
||||
"RAM 1500 LARAMIE MEGA CAB",
|
||||
"RAM 1500 LARAMIE QUAD CAB",
|
||||
"RAM 1500 LARAMIE SLT QUAD CAB",
|
||||
"RAM 1500 LIMITED CREW CAB",
|
||||
"RAM 1500 LONGHORN CREW CAB",
|
||||
"RAM 1500 OUTDOORSMAN CREW CAB",
|
||||
"RAM 1500 OUTDOORSMAN QC (AMAL)",
|
||||
"RAM 1500 OUTDOORSMAN QUAD CAB",
|
||||
"RAM 1500 QUAD CAB",
|
||||
"RAM 1500 R/T",
|
||||
"RAM 1500 REBEL CREW CAB",
|
||||
"RAM 1500 REBEL QUAD CAB",
|
||||
"RAM 1500 SLT",
|
||||
"RAM 1500 SLT CREW (AMALGAMATE)",
|
||||
"RAM 1500 SLT CREW CAB",
|
||||
"RAM 1500 SLT MEGA CAB",
|
||||
"RAM 1500 SLT QUAD (AMALGAMATE)",
|
||||
"RAM 1500 SLT QUAD CAB",
|
||||
"RAM 1500 SPORT",
|
||||
"RAM 1500 SPORT CLUB CAB",
|
||||
"RAM 1500 SPORT CREW CAB",
|
||||
"RAM 1500 SPORT CREW CAB (AMAL)",
|
||||
"RAM 1500 SPORT QUAD CAB",
|
||||
"RAM 1500 ST",
|
||||
"RAM 1500 ST CREW CAB",
|
||||
"RAM 1500 ST QUAD CAB",
|
||||
"RAM 1500 SXT CREW CAB",
|
||||
"RAM 1500 SXT QUAD CAB",
|
||||
"RAM 1500 TRADESMAN CREW CAB",
|
||||
"RAM 1500 TRADESMAN QUAD CAB",
|
||||
"RAM 1500 TRX QUAD CAB",
|
||||
"RAM 2500",
|
||||
"RAM 2500 BIG HORN CREW CAB",
|
||||
"RAM 2500 BIG HORN MEGA CAB",
|
||||
"RAM 2500 CLUB CAB",
|
||||
"RAM 2500 LARAMIE CREW CAB",
|
||||
"RAM 2500 LARAMIE LONGHORN CREW",
|
||||
"RAM 2500 LARAMIE LONGHORN MEGA",
|
||||
"RAM 2500 LARAMIE MEGA CAB",
|
||||
"RAM 2500 LARAMIE QUAD CAB",
|
||||
"RAM 2500 LARAMIE SLT",
|
||||
"RAM 2500 LARAMIE SLT QUAD CAB",
|
||||
"RAM 2500 LIMITED CREW CAB",
|
||||
"RAM 2500 OUTDOORSMAN CREW CAB",
|
||||
"RAM 2500 POWER WAGON CREW CAB",
|
||||
"RAM 2500 QUAD CAB",
|
||||
"RAM 2500 SLT",
|
||||
"RAM 2500 SLT CREW CAB",
|
||||
"RAM 2500 SLT MEGA CAB",
|
||||
"RAM 2500 SLT QUAD CAB",
|
||||
"RAM 2500 SLT QUAD CAB (AMALGA)",
|
||||
"RAM 2500 SPORT QUAD CAB",
|
||||
"RAM 2500 ST",
|
||||
"RAM 2500 ST CREW CAB",
|
||||
"RAM 2500 ST QUAD CAB",
|
||||
"RAM 2500 SXT QUAD CAB",
|
||||
"RAM 2500 TRADESMAN",
|
||||
"RAM 2500 TRADESMAN CREW CAB",
|
||||
"RAM 2500 TRX CREW CAB",
|
||||
"RAM 2500 TRX QUAD CAB",
|
||||
"RAM 3500",
|
||||
"RAM 3500 4WD",
|
||||
"RAM 3500 BIG HORN CREW CAB",
|
||||
"RAM 3500 CREW CAB",
|
||||
"RAM 3500 CREW CAB (AMALGAMATE)",
|
||||
"RAM 3500 LARAMIE CREW CAB",
|
||||
"RAM 3500 LARAMIE LONGHORN CREW",
|
||||
"RAM 3500 LARAMIE LONGHORN MEGA",
|
||||
"RAM 3500 LARAMIE MEGA CAB",
|
||||
"RAM 3500 LARAMIE QUAD CAB",
|
||||
"RAM 3500 LARAMIE SLT",
|
||||
"RAM 3500 LARAMIE SLT QUAD CAB",
|
||||
"RAM 3500 LIMITED MEGA CAB",
|
||||
"RAM 3500 LONGHORN CREW CAB",
|
||||
"RAM 3500 QUAD CAB",
|
||||
"RAM 3500 SLT",
|
||||
"RAM 3500 SLT CREW CAB",
|
||||
"RAM 3500 SLT MEGA CAB",
|
||||
"RAM 3500 SLT QUAD CAB",
|
||||
"RAM 3500 SPORT QUAD CAB",
|
||||
"RAM 3500 ST",
|
||||
"RAM 3500 ST CREW CAB",
|
||||
"RAM 3500 ST QUAD CAB",
|
||||
"RAM 3500 TRX QUAD CAB",
|
||||
"RAM 4500",
|
||||
"RAM 4500 CREW CAB",
|
||||
"RAM 5500",
|
||||
"RAM 5500 CREW CAB",
|
||||
"W250 TURBO DIESEL",
|
||||
|
||||
"C Series 5500",
|
||||
"C/R 1500 4+CAB",
|
||||
"C/R 1500 PICKUP",
|
||||
"C/R 1500 SIERRA SL EXT CAB",
|
||||
"C/R 3500",
|
||||
"C/R 3500 PICKUP",
|
||||
"CANYON ALL TERRAIN CREW CAB",
|
||||
"CANYON CREW CAB",
|
||||
"CANYON DENALI CREW CAB",
|
||||
"CANYON EXT CAB",
|
||||
"CANYON SL",
|
||||
"CANYON SL EXT CAB",
|
||||
"CANYON SLE",
|
||||
"CANYON SLE CREW CAB",
|
||||
"CANYON SLE EXT CAB",
|
||||
"CANYON SLT CREW CAB",
|
||||
"CANYON SLT CREW CAB (AMALGAMA)",
|
||||
"K/V 1500 4+CAB",
|
||||
"K/V 1500 PICKUP",
|
||||
"K/V 2500 4+CAB",
|
||||
"K/V 2500 PICKUP",
|
||||
"K/V 3500 SIERRA SL CREW CAB",
|
||||
"K/V 3500 SIERRA SLE CREW CAB",
|
||||
"SIERRA 1500 AT4 CREW CAB",
|
||||
"SIERRA 1500 AT4 DOUBLE CAB",
|
||||
"SIERRA 1500 CREW CAB",
|
||||
"SIERRA 1500 CREW CAB (AMALGAM)",
|
||||
"SIERRA 1500 DENALI CREW CAB",
|
||||
"SIERRA 1500 DENALI EXT CAB",
|
||||
"SIERRA 1500 DOUBLE CAB",
|
||||
"SIERRA 1500 ELEVATION CREW CAB",
|
||||
"SIERRA 1500 ELEVATION DC",
|
||||
"SIERRA 1500 EXT CAB",
|
||||
"SIERRA 1500 HD CREW CAB",
|
||||
"SIERRA 1500 HD SLE CREW CAB",
|
||||
"SIERRA 1500 HD SLT CREW CAB",
|
||||
"SIERRA 1500 NEVADA EDITION",
|
||||
"SIERRA 1500 PICKUP",
|
||||
"SIERRA 1500 SL CREW CAB",
|
||||
"SIERRA 1500 SL EXT CAB",
|
||||
"SIERRA 1500 SL PICKUP",
|
||||
"SIERRA 1500 SLE CREW CAB",
|
||||
"SIERRA 1500 SLE DC (AMALGAMAT)",
|
||||
"SIERRA 1500 SLE DOUBLE CAB",
|
||||
"SIERRA 1500 SLE EXT CAB",
|
||||
"SIERRA 1500 SLE EXT CAB (AMAL)",
|
||||
"SIERRA 1500 SLE PICKUP",
|
||||
"SIERRA 1500 SLT CREW (AMALGAM)",
|
||||
"SIERRA 1500 SLT CREW CAB",
|
||||
"SIERRA 1500 SLT DOUBLE CAB",
|
||||
"SIERRA 1500 SLT EXT CAB",
|
||||
"SIERRA 1500 WT CREW CAB",
|
||||
"SIERRA 1500 WT EXT CAB",
|
||||
"SIERRA 1500 WT PICKUP",
|
||||
"SIERRA 2500 EXT CAB",
|
||||
"SIERRA 2500 HD AT4 CREW CAB",
|
||||
"SIERRA 2500 HD CREW CAB",
|
||||
"SIERRA 2500 HD DENALI CREW CAB",
|
||||
"SIERRA 2500 HD DOUBLE CAB",
|
||||
"SIERRA 2500 HD EXT CAB",
|
||||
"SIERRA 2500 HD PICKUP",
|
||||
"SIERRA 2500 HD SL EXT CAB",
|
||||
"SIERRA 2500 HD SL PICKUP",
|
||||
"SIERRA 2500 HD SLE CREW CAB",
|
||||
"SIERRA 2500 HD SLE DOUBLE CAB",
|
||||
"SIERRA 2500 HD SLE EXT CAB",
|
||||
"SIERRA 2500 HD SLE PICKUP",
|
||||
"SIERRA 2500 HD SLT CREW CAB",
|
||||
"SIERRA 2500 HD SLT DOUBLE CAB",
|
||||
"SIERRA 2500 HD SLT EXT CAB",
|
||||
"SIERRA 2500 HD WT CREW CAB",
|
||||
"SIERRA 2500 HD WT DOUBLE CAB",
|
||||
"SIERRA 2500 HD WT EXT CAB",
|
||||
"SIERRA 2500 HD WT PICKUP",
|
||||
"SIERRA 2500 SLE EXT CAB",
|
||||
"SIERRA 3500 AT4 CREW CAB",
|
||||
"SIERRA 3500 CREW CAB",
|
||||
"SIERRA 3500 DENALI CREW CAB",
|
||||
"SIERRA 3500 EXT CAB",
|
||||
"SIERRA 3500 PICKUP",
|
||||
"SIERRA 3500 SL CREW CAB",
|
||||
"SIERRA 3500 SLE",
|
||||
"SIERRA 3500 SLE CREW CAB",
|
||||
"SIERRA 3500 SLE EXT CAB",
|
||||
"SIERRA 3500 SLT CREW CAB",
|
||||
"SIERRA 3500 WT CREW CAB",
|
||||
"SONOMA",
|
||||
"SONOMA CREW CAB",
|
||||
"SONOMA EXT CAB",
|
||||
|
||||
"1500",
|
||||
"1500 Classic",
|
||||
"Pickup 1500",
|
||||
"Pickup 3500",
|
||||
"ProMaster 1500",
|
||||
|
||||
"RIDGELINE",
|
||||
"RIDGELINE BLACK EDITION",
|
||||
"RIDGELINE DX",
|
||||
"RIDGELINE EX-L",
|
||||
"RIDGELINE LX",
|
||||
"RIDGELINE RT",
|
||||
"RIDGELINE RTL",
|
||||
"RIDGELINE RTS",
|
||||
"RIDGELINE RTX",
|
||||
"RIDGELINE SE",
|
||||
"RIDGELINE SPORT",
|
||||
"RIDGELINE TOURING",
|
||||
"RIDGELINE VP",
|
||||
|
||||
"TITAN",
|
||||
"TACOMA",
|
||||
"TUNDRA",
|
||||
"AVALANCE",
|
||||
"COLORADO",
|
||||
"SILVERADO",
|
||||
"SILVERADO 1500",
|
||||
"SILVERADO 2500",
|
||||
"SILVERADO 3500",
|
||||
"DAKOTA",
|
||||
"RAM 1500",
|
||||
"RAM 2500",
|
||||
"RAM 3500",
|
||||
"RAM 4500",
|
||||
"RAM 5500",
|
||||
"CANYON",
|
||||
"SIERRA 1500",
|
||||
"SIERRA 2500",
|
||||
"SIERRA 3500",
|
||||
"SONOMA",
|
||||
"1500"
|
||||
]
|
||||
@@ -1,39 +0,0 @@
|
||||
const logger = require("../../utils/logger");
|
||||
const TrucksList = require("./trucks.json");
|
||||
const CargoVanList = require("./cargovans.json");
|
||||
const PassengerVanList = require("./passengervans.json");
|
||||
const SuvList = require("./suvs.json");
|
||||
|
||||
|
||||
const vehicletype = async (req, res) => {
|
||||
try {
|
||||
const { model } = req.body;
|
||||
if (!model || model.trim() === "") {
|
||||
res.status(400).json({ success: false, error: "Please provide a model" });
|
||||
} else {
|
||||
vehicle
|
||||
const type = getVehicleType(model.trim())
|
||||
res.status(200).json({ success: true, ...type });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log("vehicletype-error", "ERROR", req?.user?.email, null, {
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
res.status(500).json({ error: error.message, stack: error.stack });
|
||||
}
|
||||
};
|
||||
|
||||
function getVehicleType(model) {
|
||||
const inTrucks = TrucksList.includes(model.toUpperCase());
|
||||
const inPV = PassengerVanList.includes(model.toUpperCase());
|
||||
const inSuv = SuvList.includes(model.toUpperCase());
|
||||
const inCv = CargoVanList.includes(model.toUpperCase());
|
||||
|
||||
if (inTrucks) return { type: "TK", match: true };
|
||||
else if (inPV) return { type: "PC", match: true };
|
||||
else if (inSuv) return { type: "SUV", match: true };
|
||||
else if (inCv) return { type: "VN", match: true };
|
||||
else return { type: "PC", match: false };
|
||||
}
|
||||
exports.default = vehicletype;
|
||||
@@ -235,18 +235,6 @@ async function MakeFortellisCall({
|
||||
// jobid: socket?.recordid
|
||||
// });
|
||||
|
||||
if (result.data.checkStatusAfterSeconds) {
|
||||
return DelayedCallback({
|
||||
delayMeta: result.data,
|
||||
access_token,
|
||||
SubscriptionID: SubscriptionMeta.subscriptionId,
|
||||
ReqId,
|
||||
departmentIds: DepartmentId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
logger.log(
|
||||
"fortellis-log-event-json",
|
||||
"DEBUG",
|
||||
@@ -261,6 +249,18 @@ async function MakeFortellisCall({
|
||||
},
|
||||
);
|
||||
|
||||
if (result.data.checkStatusAfterSeconds) {
|
||||
return DelayedCallback({
|
||||
delayMeta: result.data,
|
||||
access_token,
|
||||
SubscriptionID: SubscriptionMeta.subscriptionId,
|
||||
ReqId,
|
||||
departmentIds: DepartmentId,
|
||||
jobid,
|
||||
socket
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
const errorDetails = {
|
||||
@@ -310,7 +310,7 @@ async function MakeFortellisCall({
|
||||
//Some Fortellis calls return a batch result that isn't ready immediately.
|
||||
//This function will check the status of the call and wait until it is ready.
|
||||
//It will try 5 times before giving up.
|
||||
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds }) {
|
||||
async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId, departmentIds, jobid, socket }) {
|
||||
for (let index = 0; index < 5; index++) {
|
||||
await sleep(delayMeta.checkStatusAfterSeconds * 1000);
|
||||
//Check to see if the call is ready.
|
||||
@@ -334,6 +334,19 @@ async function DelayedCallback({ delayMeta, access_token, SubscriptionID, ReqId,
|
||||
//"Department-Id": departmentIds[0].id
|
||||
}
|
||||
});
|
||||
logger.log(
|
||||
"fortellis-log-event-json-DelayedCallback",
|
||||
"DEBUG",
|
||||
socket?.user?.email,
|
||||
jobid,
|
||||
{
|
||||
requestcurl: batchResult.config.curlCommand,
|
||||
reqid: batchResult.config.headers["Request-Id"] || null,
|
||||
subscriptionId: batchResult.config.headers["Subscription-Id"] || null,
|
||||
resultdata: batchResult.data,
|
||||
resultStatus: batchResult.status
|
||||
},
|
||||
);
|
||||
// await writeFortellisLogToFile({
|
||||
// timestamp: new Date().toISOString(),
|
||||
// reqId: ReqId,
|
||||
|
||||
@@ -198,7 +198,7 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
);
|
||||
const existingCustomerInDMSCustList = DMSCustList.find((c) => c.customerId === selectedCustomerId);
|
||||
DMSCust = existingCustomerInDMSCustList || {
|
||||
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
|
||||
customerId: selectedCustomerId //This is the fall back in case it is the generic customer.
|
||||
};
|
||||
await setSessionTransactionData(
|
||||
socket.id,
|
||||
@@ -207,8 +207,6 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
DMSCust,
|
||||
defaultFortellisTTL
|
||||
);
|
||||
|
||||
|
||||
} else {
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{3.2} Creating new customer.`);
|
||||
const DMSCustomerInsertResponse = await InsertDmsCustomer({ socket, redisHelpers, JobData });
|
||||
@@ -227,14 +225,10 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{4.1} Inserting new vehicle with ID: ID ${DMSVid.vehiclesVehId}`);
|
||||
DMSVeh = await InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMSVid, DMSCust });
|
||||
} else {
|
||||
DMSVeh = await getSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(jobid),
|
||||
FortellisCacheEnums.DMSVeh
|
||||
)
|
||||
DMSVeh = await getSessionTransactionData(socket.id, getTransactionType(jobid), FortellisCacheEnums.DMSVeh);
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{4.3} Updating Existing Vehicle to associate to owner.`);
|
||||
|
||||
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
|
||||
//Check to see if the vehicle needs to be updated - i.e. the owner is not the selected customer.
|
||||
if (!DMSVeh?.owners.find((o) => o.id.value === DMSCust.customerId && o.id.assigningPartyId === "CURRENT")) {
|
||||
DMSVeh = await UpdateDmsVehicle({
|
||||
socket,
|
||||
@@ -271,7 +265,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
|
||||
if (DMSTransHeader.rtnCode === "0") {
|
||||
try {
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`);
|
||||
CreateFortellisLogEvent(
|
||||
socket,
|
||||
"DEBUG",
|
||||
`{6} Attempting to post Transaction with ID ${DMSTransHeader.transID}`
|
||||
);
|
||||
|
||||
const DmsBatchTxnPost = await PostDmsBatchWip({ socket, redisHelpers, JobData }); // 2 in 1 call that includes a post and the transactions.
|
||||
await setSessionTransactionData(
|
||||
@@ -282,16 +280,14 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
defaultFortellisTTL
|
||||
);
|
||||
|
||||
|
||||
if (DmsBatchTxnPost.rtnCode === "0") {
|
||||
//TODO: Validate this is a string and not #
|
||||
//something
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{6} Successfully posted transaction to DMS.`);
|
||||
|
||||
await MarkJobExported({ socket, jobid: JobData.id, JobData });
|
||||
await MarkJobExported({ socket, jobid: JobData.id, JobData, redisHelpers });
|
||||
|
||||
try {
|
||||
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{7} Updating Service Vehicle History.`);
|
||||
const DMSVehHistory = await InsertServiceVehicleHistory({ socket, redisHelpers, JobData });
|
||||
await setSessionTransactionData(
|
||||
@@ -302,24 +298,19 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
defaultFortellisTTL
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
CreateFortellisLogEvent(socket, "ERROR", `{7.1} Error posting vehicle service history. ${error.message}`);
|
||||
}
|
||||
|
||||
socket.emit("export-success", JobData.id);
|
||||
} else {
|
||||
//There was something wrong. Throw an error to trigger clean up.
|
||||
//There was something wrong. Throw an error to trigger clean up.
|
||||
//throw new Error("Error posting DMS Batch Transaction");
|
||||
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
//Clean up the transaction and insert a faild error code
|
||||
// //Get the error code
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{6.1} Getting errors for Transaction ID ${DMSTransHeader.transID}`);
|
||||
|
||||
|
||||
const DmsError = await QueryDmsErrWip({ socket, redisHelpers, JobData });
|
||||
// //Delete the transaction
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `{6.2} Deleting Transaction ID ${DMSTransHeader.transID}`);
|
||||
@@ -345,7 +336,11 @@ async function FortellisSelectedCustomer({ socket, redisHelpers, selectedCustome
|
||||
stack: error.stack,
|
||||
data: error.errorData
|
||||
});
|
||||
await InsertFailedExportLog({ socket, JobData, error: error.errorData?.issues || [JSON.stringify(error.errorData)] });
|
||||
await InsertFailedExportLog({
|
||||
socket,
|
||||
JobData,
|
||||
error: error.errorData?.issues || [JSON.stringify(error.errorData)]
|
||||
});
|
||||
} finally {
|
||||
//Ensure we always insert logEvents
|
||||
//GQL to insert logevents.
|
||||
@@ -374,7 +369,7 @@ async function CalculateDmsVid({ socket, JobData, redisHelpers }) {
|
||||
jobid: JobData.id,
|
||||
body: {}
|
||||
});
|
||||
return result;
|
||||
return Array.isArray(result) ? result.filter((v) => v.vehiclesVehId !== null && v.vehiclesVehId !== "") : [];
|
||||
} catch (error) {
|
||||
handleFortellisApiError(socket, error, "CalculateDmsVid", {
|
||||
vin: JobData.v_vin,
|
||||
@@ -429,12 +424,12 @@ async function QueryDmsCustomerById({ socket, redisHelpers, JobData, CustomerId
|
||||
async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
|
||||
const ownerName =
|
||||
JobData.ownr_co_nm && JobData.ownr_co_nm.trim() !== ""
|
||||
//? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
|
||||
? [["phone", JobData.ownr_ph1?.replace(replaceSpecialRegex, "")]]
|
||||
? //? [["firstName", JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()]] // Commented out until we receive direction.
|
||||
[["phone", JobData.ownr_ph1?.replace(/[^0-9]/g, "")]]
|
||||
: [
|
||||
["firstName", JobData.ownr_fn?.replace(replaceSpecialRegex, "").toUpperCase()],
|
||||
["lastName", JobData.ownr_ln?.replace(replaceSpecialRegex, "").toUpperCase()]
|
||||
];
|
||||
["firstName", JobData.ownr_fn?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()],
|
||||
["lastName", JobData.ownr_ln?.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()]
|
||||
];
|
||||
try {
|
||||
const result = await MakeFortellisCall({
|
||||
...FortellisActions.QueryCustomerByName,
|
||||
@@ -457,7 +452,7 @@ async function QueryDmsCustomerByName({ socket, redisHelpers, JobData }) {
|
||||
|
||||
async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
|
||||
try {
|
||||
const isBusiness = (JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "")
|
||||
const isBusiness = JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").trim() !== "";
|
||||
const result = await MakeFortellisCall({
|
||||
...FortellisActions.CreateCustomer,
|
||||
headers: {},
|
||||
@@ -466,21 +461,23 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
|
||||
jobid: JobData.id,
|
||||
body: {
|
||||
customerType: isBusiness ? "BUSINESS" : "INDIVIDUAL",
|
||||
...isBusiness ? {
|
||||
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
|
||||
secondaryCustomerName: {
|
||||
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
|
||||
}
|
||||
} : {
|
||||
customerName: {
|
||||
//"suffix": "Mr.",
|
||||
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(replaceSpecialRegex, "").toUpperCase(),
|
||||
//"middleName": "",
|
||||
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(replaceSpecialRegex, "").toUpperCase()
|
||||
//"title": "",
|
||||
//"nickName": ""
|
||||
}
|
||||
},
|
||||
...(isBusiness
|
||||
? {
|
||||
companyName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase(),
|
||||
secondaryCustomerName: {
|
||||
//lastName: JobData.ownr_co_nm && JobData.ownr_co_nm.replace(replaceSpecialRegex, "").toUpperCase()
|
||||
}
|
||||
}
|
||||
: {
|
||||
customerName: {
|
||||
//"suffix": "Mr.",
|
||||
firstName: JobData.ownr_fn && JobData.ownr_fn.replace(/[^a-zA-Z0-9]/g, "").toUpperCase(),
|
||||
//"middleName": "",
|
||||
lastName: JobData.ownr_ln && JobData.ownr_ln.replace(/[^a-zA-Z0-9]/g, "").toUpperCase()
|
||||
//"title": "",
|
||||
//"nickName": ""
|
||||
}
|
||||
}),
|
||||
postalAddress: {
|
||||
addressLine1: JobData.ownr_addr1?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
|
||||
addressLine2: JobData.ownr_addr2?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
|
||||
@@ -490,7 +487,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
|
||||
rome: JobData.ownr_zip
|
||||
}),
|
||||
state: JobData.ownr_st?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
|
||||
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase(),
|
||||
country: JobData.ownr_ctry?.replace(replaceSpecialRegex, "").trim().toUpperCase()
|
||||
//"territory": ""
|
||||
},
|
||||
// "birthDate": {
|
||||
@@ -504,7 +501,7 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
|
||||
phones: [
|
||||
{
|
||||
//"uuid": "",
|
||||
number: JobData.ownr_ph1?.replace(replaceSpecialRegex, ""),
|
||||
number: JobData.ownr_ph1?.replace(/[^0-9]/g, ""),
|
||||
type: "HOME"
|
||||
// "doNotCallIndicator": true,
|
||||
// "doNotCallIndicatorDate": `null,
|
||||
@@ -540,18 +537,18 @@ async function InsertDmsCustomer({ socket, redisHelpers, JobData }) {
|
||||
emailAddresses: [
|
||||
...(!_.isEmpty(JobData.ownr_ea)
|
||||
? [
|
||||
{
|
||||
//"uuid": "",
|
||||
address: JobData.ownr_ea.toUpperCase(),
|
||||
type: "PERSONAL"
|
||||
// "doNotEmailSource": "",
|
||||
// "doNotEmail": false,
|
||||
// "isPreferred": true,
|
||||
// "transactionEmailNotificationOptIn": false,
|
||||
// "optInRequestDate": null,
|
||||
// "optInDate": null
|
||||
}
|
||||
]
|
||||
{
|
||||
//"uuid": "",
|
||||
address: JobData.ownr_ea.toUpperCase(),
|
||||
type: "PERSONAL"
|
||||
// "doNotEmailSource": "",
|
||||
// "doNotEmail": false,
|
||||
// "isPreferred": true,
|
||||
// "transactionEmailNotificationOptIn": false,
|
||||
// "optInRequestDate": null,
|
||||
// "optInDate": null
|
||||
}
|
||||
]
|
||||
: [])
|
||||
// {
|
||||
// "uuid": "",
|
||||
@@ -691,9 +688,9 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
|
||||
txEnvelope.dms_unsold === true
|
||||
? ""
|
||||
: moment(txEnvelope.inservicedate)
|
||||
//.tz(JobData.bodyshop.timezone)
|
||||
.startOf("day")
|
||||
.toISOString()
|
||||
//.tz(JobData.bodyshop.timezone)
|
||||
.startOf("day")
|
||||
.toISOString()
|
||||
}),
|
||||
//"lastServiceDate": "2011-11-23",
|
||||
vehicleId: DMSVid.vehiclesVehId
|
||||
@@ -735,8 +732,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
|
||||
txEnvelope.dms_unsold === true
|
||||
? ""
|
||||
: moment()
|
||||
// .tz(JobData.bodyshop.timezone)
|
||||
.format("YYYY-MM-DD"),
|
||||
// .tz(JobData.bodyshop.timezone)
|
||||
.format("YYYY-MM-DD"),
|
||||
// "deliveryMileage": 4,
|
||||
// "doorsQuantity": 4,
|
||||
// "engineNumber": "",
|
||||
@@ -753,8 +750,8 @@ async function InsertDmsVehicle({ socket, redisHelpers, JobData, txEnvelope, DMS
|
||||
: String(JobData.plate_no).replace(/([^\w]|_)/g, "").length === 0
|
||||
? null
|
||||
: String(JobData.plate_no)
|
||||
.replace(/([^\w]|_)/g, "")
|
||||
.toUpperCase(),
|
||||
.replace(/([^\w]|_)/g, "")
|
||||
.toUpperCase(),
|
||||
make: txEnvelope.dms_make,
|
||||
// "model": "CC10753",
|
||||
modelAbrev: txEnvelope.dms_model,
|
||||
@@ -900,13 +897,13 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
|
||||
},
|
||||
...(oldOwner
|
||||
? [
|
||||
{
|
||||
id: {
|
||||
assigningPartyId: "PREVIOUS",
|
||||
value: oldOwner.id.value
|
||||
{
|
||||
id: {
|
||||
assigningPartyId: "PREVIOUS",
|
||||
value: oldOwner.id.value
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
: [])
|
||||
];
|
||||
}
|
||||
@@ -936,24 +933,24 @@ async function UpdateDmsVehicle({ socket, redisHelpers, JobData, DMSVeh, DMSCust
|
||||
txEnvelope.dms_unsold === true
|
||||
? ""
|
||||
: moment(DMSVehToSend.dealer.inServiceDate || txEnvelope.inservicedate)
|
||||
// .tz(JobData.bodyshop.timezone)
|
||||
.toISOString()
|
||||
// .tz(JobData.bodyshop.timezone)
|
||||
.toISOString()
|
||||
})
|
||||
},
|
||||
vehicle: {
|
||||
...DMSVehToSend.vehicle,
|
||||
...(txEnvelope.dms_model_override
|
||||
? {
|
||||
make: txEnvelope.dms_make,
|
||||
modelAbrev: txEnvelope.dms_model
|
||||
}
|
||||
make: txEnvelope.dms_make,
|
||||
modelAbrev: txEnvelope.dms_model
|
||||
}
|
||||
: {}),
|
||||
deliveryDate:
|
||||
txEnvelope.dms_unsold === true
|
||||
? ""
|
||||
: moment(DMSVehToSend.vehicle.deliveryDate)
|
||||
//.tz(JobData.bodyshop.timezone)
|
||||
.toISOString()
|
||||
//.tz(JobData.bodyshop.timezone)
|
||||
.toISOString()
|
||||
},
|
||||
owners: ids
|
||||
}
|
||||
@@ -1061,7 +1058,7 @@ async function InsertDmsStartWip({ socket, redisHelpers, JobData }) {
|
||||
// transID: "",
|
||||
// userID: "partprgm",
|
||||
// userName: "PROGRAM, PARTNER"
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1091,9 +1088,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
acct: alloc.profitCenter.dms_acctnumber,
|
||||
cntl:
|
||||
alloc.profitCenter.dms_control_override &&
|
||||
alloc.profitCenter.dms_control_override !== null &&
|
||||
alloc.profitCenter.dms_control_override !== undefined &&
|
||||
alloc.profitCenter.dms_control_override?.trim() !== ""
|
||||
alloc.profitCenter.dms_control_override !== null &&
|
||||
alloc.profitCenter.dms_control_override !== undefined &&
|
||||
alloc.profitCenter.dms_control_override?.trim() !== ""
|
||||
? alloc.profitCenter.dms_control_override
|
||||
: JobData.ro_number,
|
||||
cntl2: null,
|
||||
@@ -1114,9 +1111,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
acct: alloc.costCenter.dms_acctnumber,
|
||||
cntl:
|
||||
alloc.costCenter.dms_control_override &&
|
||||
alloc.costCenter.dms_control_override !== null &&
|
||||
alloc.costCenter.dms_control_override !== undefined &&
|
||||
alloc.costCenter.dms_control_override?.trim() !== ""
|
||||
alloc.costCenter.dms_control_override !== null &&
|
||||
alloc.costCenter.dms_control_override !== undefined &&
|
||||
alloc.costCenter.dms_control_override?.trim() !== ""
|
||||
? alloc.costCenter.dms_control_override
|
||||
: JobData.ro_number,
|
||||
cntl2: null,
|
||||
@@ -1134,9 +1131,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
acct: alloc.costCenter.dms_wip_acctnumber,
|
||||
cntl:
|
||||
alloc.costCenter.dms_control_override &&
|
||||
alloc.costCenter.dms_control_override !== null &&
|
||||
alloc.costCenter.dms_control_override !== undefined &&
|
||||
alloc.costCenter.dms_control_override?.trim() !== ""
|
||||
alloc.costCenter.dms_control_override !== null &&
|
||||
alloc.costCenter.dms_control_override !== undefined &&
|
||||
alloc.costCenter.dms_control_override?.trim() !== ""
|
||||
? alloc.costCenter.dms_control_override
|
||||
: JobData.ro_number,
|
||||
cntl2: null,
|
||||
@@ -1158,9 +1155,9 @@ async function GenerateTransWips({ socket, redisHelpers, JobData }) {
|
||||
acct: alloc.profitCenter.dms_acctnumber,
|
||||
cntl:
|
||||
alloc.profitCenter.dms_control_override &&
|
||||
alloc.profitCenter.dms_control_override !== null &&
|
||||
alloc.profitCenter.dms_control_override !== undefined &&
|
||||
alloc.profitCenter.dms_control_override?.trim() !== ""
|
||||
alloc.profitCenter.dms_control_override !== null &&
|
||||
alloc.profitCenter.dms_control_override !== undefined &&
|
||||
alloc.profitCenter.dms_control_override?.trim() !== ""
|
||||
? alloc.profitCenter.dms_control_override
|
||||
: JobData.ro_number,
|
||||
cntl2: null,
|
||||
@@ -1228,7 +1225,7 @@ async function PostDmsBatchWip({ socket, redisHelpers, JobData }) {
|
||||
opCode: "P",
|
||||
transID: DMSTransHeader.transID,
|
||||
transWipReqList: await GenerateTransWips({ socket, redisHelpers, JobData })
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1256,7 +1253,7 @@ async function QueryDmsErrWip({ socket, redisHelpers, JobData }) {
|
||||
socket,
|
||||
jobid: JobData.id,
|
||||
requestPathParams: DMSTransHeader.transID,
|
||||
body: {},
|
||||
body: {}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1286,7 +1283,7 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
|
||||
body: {
|
||||
opCode: "D",
|
||||
transID: DMSTransHeader.transID
|
||||
},
|
||||
}
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
@@ -1298,9 +1295,15 @@ async function DeleteDmsWip({ socket, redisHelpers, JobData }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function MarkJobExported({ socket, jobid, JobData }) {
|
||||
async function MarkJobExported({ socket, jobid, JobData, redisHelpers }) {
|
||||
CreateFortellisLogEvent(socket, "DEBUG", `Marking job as exported for id ${jobid}`);
|
||||
|
||||
const transwips = await redisHelpers.getSessionTransactionData(
|
||||
socket.id,
|
||||
getTransactionType(JobData.id),
|
||||
FortellisCacheEnums.transWips
|
||||
);
|
||||
|
||||
const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, {});
|
||||
const currentToken =
|
||||
(socket?.data && socket.data.authToken) || (socket?.handshake?.auth && socket.handshake.auth.token);
|
||||
@@ -1318,7 +1321,7 @@ async function MarkJobExported({ socket, jobid, JobData }) {
|
||||
jobid: jobid,
|
||||
successful: true,
|
||||
useremail: socket.user.email,
|
||||
metadata: socket.transWips
|
||||
metadata: transwips
|
||||
},
|
||||
bill: {
|
||||
exported: true,
|
||||
@@ -1338,13 +1341,15 @@ async function InsertFailedExportLog({ socket, JobData, error }) {
|
||||
const result = await client
|
||||
.setHeaders({ Authorization: `Bearer ${currentToken}` })
|
||||
.request(queries.INSERT_EXPORT_LOG, {
|
||||
logs: [{
|
||||
bodyshopid: JobData.bodyshop.id,
|
||||
jobid: JobData.id,
|
||||
successful: false,
|
||||
message: JSON.stringify(error),
|
||||
useremail: socket.user.email
|
||||
}]
|
||||
logs: [
|
||||
{
|
||||
bodyshopid: JobData.bodyshop.id,
|
||||
jobid: JobData.id,
|
||||
successful: false,
|
||||
message: JSON.stringify(error),
|
||||
useremail: socket.user.email
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
17
server/routes/aiRoutes.js
Normal file
17
server/routes/aiRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require("express");
|
||||
const router = express.Router();
|
||||
const multer = require("multer");
|
||||
const validateFirebaseIdTokenMiddleware = require("../middleware/validateFirebaseIdTokenMiddleware");
|
||||
const withUserGraphQLClientMiddleware = require("../middleware/withUserGraphQLClientMiddleware");
|
||||
const { handleBillOcr, handleBillOcrStatus } = require("../ai/bill-ocr/bill-ocr");
|
||||
|
||||
// Configure multer for form data parsing
|
||||
const upload = multer();
|
||||
|
||||
router.use(validateFirebaseIdTokenMiddleware);
|
||||
router.use(withUserGraphQLClientMiddleware);
|
||||
|
||||
router.post("/bill-ocr", upload.single('billScan'), handleBillOcr);
|
||||
router.get("/bill-ocr/status/:textractJobId", handleBillOcrStatus);
|
||||
|
||||
module.exports = router;
|
||||
@@ -144,20 +144,5 @@ router.post("/emsupload", validateFirebaseIdTokenMiddleware, data.emsUpload);
|
||||
// Redis Cache Routes
|
||||
router.post("/bodyshop-cache", eventAuthorizationMiddleware, updateBodyshopCache);
|
||||
|
||||
// Estimate Scrubber Vehicle Type
|
||||
router.post("/es/vehicletype", data.vehicletype);
|
||||
router.post("/analytics/documents", data.documentAnalytics);
|
||||
// Health Check for docker-compose-cluster load balancer, only available in development
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
router.get("/health", (req, res) => {
|
||||
const healthStatus = {
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV || "unknown",
|
||||
uptime: process.uptime()
|
||||
};
|
||||
res.status(200).json(healthStatus);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -120,7 +120,12 @@ const createMinimalRRRepairOrder = async (args) => {
|
||||
advisorNo: String(advisorNo),
|
||||
vin: cleanVin,
|
||||
departmentType: "B",
|
||||
outsdRoNo: job?.ro_number || job?.id || undefined
|
||||
outsdRoNo: job?.ro_number || job?.id || undefined,
|
||||
estimate: {
|
||||
parts: "0",
|
||||
labor: "0",
|
||||
total: "0.00"
|
||||
}
|
||||
};
|
||||
|
||||
// Only add mileageIn if we have a valid value
|
||||
|
||||
Reference in New Issue
Block a user