diff --git a/bodyshop_translations.babel b/bodyshop_translations.babel index ad5db59a6..d6fc28db9 100644 --- a/bodyshop_translations.babel +++ b/bodyshop_translations.babel @@ -2181,6 +2181,27 @@ + + existinginventoryline + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + exporting false @@ -3293,6 +3314,27 @@ validation + + inventoryquantity + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + manualinhouse false @@ -5691,6 +5733,53 @@ + + inventory + + + delete + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + list + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + jobs @@ -15883,6 +15972,27 @@ + + markedexported + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + message false @@ -17213,6 +17323,435 @@ + + inventory + + + actions + + + addtoinventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + addtoro + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + consumefrominventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + edit + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + new + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + errors + + + inserting + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + fields + + + comment + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + manualinvoicenumber + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + manualvendor + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + labels + + + consumedbyjob + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + deleteconfirm + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + frombillinvoicenumber + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + fromvendor + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + inventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + showall + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + showavailable + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + successes + + + deleted + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + inserted + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + updated + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + + + joblines @@ -29934,6 +30473,27 @@ + + inventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + jobs false @@ -40529,6 +41089,27 @@ + + calendarperiod + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + dailyactual false @@ -40571,6 +41152,69 @@ + + jobs + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + lastmonth + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + lastweek + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + monthlytarget false @@ -40592,6 +41236,48 @@ + + productivestatistics + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + productivetimeticketsoverdate + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + targets false @@ -40613,6 +41299,69 @@ + + thismonth + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + thisweek + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + + + timetickets + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + todateactual false @@ -40634,6 +41383,27 @@ + + totaloverperiod + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + weeklyactual false @@ -40769,37 +41539,6 @@ - - scoredboard - - - successes - - - updated - false - - - - - - en-US - false - - - es-MX - false - - - fr-CA - false - - - - - - - tech @@ -42383,6 +43122,27 @@ + + inventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + jobs false @@ -43204,6 +43964,27 @@ + + inventory + false + + + + + + en-US + false + + + es-MX + false + + + fr-CA + false + + + jobs false diff --git a/client/package.json b/client/package.json index 75a510373..6efc2288f 100644 --- a/client/package.json +++ b/client/package.json @@ -4,17 +4,17 @@ "private": true, "proxy": "http://localhost:4000", "dependencies": { - "@apollo/client": "^3.6.2", + "@apollo/client": "^3.6.6", "@asseinfo/react-kanban": "^2.2.0", "@craco/craco": "^6.4.3", "@fingerprintjs/fingerprintjs": "^3.3.3", - "@sentry/react": "^6.19.7", - "@sentry/tracing": "^6.19.7", + "@sentry/react": "^7.1.1", + "@sentry/tracing": "^7.1.1", "@splitsoftware/splitio-react": "^1.4.1", - "@stripe/react-stripe-js": "^1.8.0", - "@stripe/stripe-js": "^1.29.0", - "@tanem/react-nprogress": "^5.0.0", - "antd": "^4.20.5", + "@stripe/react-stripe-js": "^1.8.1", + "@stripe/stripe-js": "^1.31.0", + "@tanem/react-nprogress": "^5.0.1", + "antd": "^4.21.0", "apollo-link-logger": "^2.0.0", "axios": "^0.27.2", "craco-less": "^1.20.0", @@ -23,19 +23,19 @@ "enquire-js": "^0.2.1", "env-cmd": "^10.1.0", "exifr": "^7.1.3", - "firebase": "^9.8.1", + "firebase": "^9.8.2", "graphql": "^16.5.0", - "i18next": "^21.8.2", + "i18next": "^21.8.9", "i18next-browser-languagedetector": "^6.1.4", - "jsoneditor": "^9.7.4", + "jsoneditor": "^9.8.0", "jsreport-browser-client-dist": "^1.3.0", - "libphonenumber-js": "^1.9.53", + "libphonenumber-js": "^1.10.6", "logrocket": "^3.0.0", "markerjs2": "^2.21.4", "moment-business-days": "^1.2.0", "moment-timezone": "^0.5.34", "normalize-url": "^7.0.3", - "phone": "^3.1.17", + "phone": "^3.1.20", "preval.macro": "^5.0.0", "prop-types": "^15.8.1", "query-string": "^7.1.1", @@ -46,11 +46,11 @@ "react-color": "^2.19.3", "react-cookie": "^4.1.1", "react-dom": "^17.0.2", - "react-drag-listview": "^0.2.0", + "react-drag-listview": "^0.2.1", "react-grid-gallery": "^0.5.5", "react-grid-layout": "^1.3.4", - "react-i18next": "^11.16.9", - "react-icons": "^4.3.1", + "react-i18next": "^11.17.0", + "react-icons": "^4.4.0", "react-number-format": "^4.9.3", "react-redux": "^7.2.8", "react-resizable": "^3.0.4", @@ -59,14 +59,14 @@ "react-sticky": "^6.0.3", "react-sublime-video": "^0.2.5", "react-virtualized": "^9.22.3", - "recharts": "^2.1.9", + "recharts": "^2.1.10", "redux": "^4.2.0", "redux-persist": "^6.0.0", "redux-saga": "^1.1.3", "redux-state-sync": "^3.1.2", - "reselect": "^4.1.5", + "reselect": "^4.1.6", "sass": "^1.51.0", - "socket.io-client": "^4.5.0", + "socket.io-client": "^4.5.1", "styled-components": "^5.3.5", "subscriptions-transport-ws": "^0.11.0", "web-vitals": "^2.1.4", diff --git a/client/src/components/bill-delete-button/bill-delete-button.component.jsx b/client/src/components/bill-delete-button/bill-delete-button.component.jsx index eee5f522b..b6cb19992 100644 --- a/client/src/components/bill-delete-button/bill-delete-button.component.jsx +++ b/client/src/components/bill-delete-button/bill-delete-button.component.jsx @@ -15,7 +15,8 @@ export default function BillDeleteButton({ bill }) { setLoading(true); const result = await deleteBill({ variables: { billId: bill.id }, - update(cache) { + update(cache, { errors }) { + if (errors) return; cache.modify({ fields: { bills(existingBills, { readField }) { @@ -36,11 +37,22 @@ export default function BillDeleteButton({ bill }) { if (!!!result.errors) { notification["success"]({ message: t("bills.successes.deleted") }); } else { - notification["error"]({ - message: t("bills.errors.deleting", { - error: JSON.stringify(result.errors), - }), - }); + //Check if it's an fkey violation. + const error = JSON.stringify(result.errors); + + if (error.toLowerCase().includes("inventory_billid_fkey")) { + notification["error"]({ + message: t("bills.errors.deleting", { + error: t("bills.errors.existinginventoryline"), + }), + }); + } else { + notification["error"]({ + message: t("bills.errors.deleting", { + error: JSON.stringify(result.errors), + }), + }); + } } setLoading(false); diff --git a/client/src/components/bill-detail-edit/bill-detail-edit.container.jsx b/client/src/components/bill-detail-edit/bill-detail-edit.container.jsx index 9bbca0a60..b9bbb791b 100644 --- a/client/src/components/bill-detail-edit/bill-detail-edit.container.jsx +++ b/client/src/components/bill-detail-edit/bill-detail-edit.container.jsx @@ -73,8 +73,8 @@ export function BillDetailEditcontainer({ sm: "100%", md: "100%", lg: "100%", - xl: "80%", - xxl: "80%", + xl: "90%", + xxl: "90%", }; const drawerPercentage = selectedBreakpoint ? bpoints[selectedBreakpoint[0]] @@ -127,7 +127,7 @@ export function BillDetailEditcontainer({ }); billlines.forEach((billline) => { - const { deductedfromlbr, jobline, ...il } = billline; + const { deductedfromlbr, inventories, jobline, ...il } = billline; delete il.__typename; if (il.id) { diff --git a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx index bfadbcd37..82948fe88 100644 --- a/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx +++ b/client/src/components/bill-enter-modal/bill-enter-modal.container.jsx @@ -12,6 +12,7 @@ import { UPDATE_JOB, } from "../../graphql/jobs.queries"; import { MUTATION_MARK_RETURN_RECEIVED } from "../../graphql/parts-orders.queries"; +import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries"; import { insertAuditTrail } from "../../redux/application/application.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions"; import { selectBillEnterModal } from "../../redux/modals/modals.selectors"; @@ -50,6 +51,7 @@ function BillEnterModalContainer({ const [insertBill] = useMutation(INSERT_NEW_BILL); const [updateJobLines] = useMutation(UPDATE_JOB_LINE); const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED); + const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES); const [loading, setLoading] = useState(false); const client = useApolloClient(); @@ -79,8 +81,13 @@ function BillEnterModalContainer({ } setLoading(true); - const { upload, location, outstanding_returns, ...remainingValues } = - values; + const { + upload, + location, + outstanding_returns, + inventory, + ...remainingValues + } = values; let adjustmentsToInsert = {}; @@ -190,6 +197,26 @@ function BillEnterModalContainer({ } const billId = r1.data.insert_bills.returning[0].id; + const markInventoryConsumed = + inventory && inventory.filter((i) => i.consumefrominventory); + + if (markInventoryConsumed && markInventoryConsumed.length > 0) { + const r2 = await updateInventoryLines({ + variables: { + InventoryIds: markInventoryConsumed.map((p) => p.id), + consumedbybillid: billId, + }, + }); + if (!!r2.errors) { + setLoading(false); + setEnterAgain(false); + notification["error"]({ + message: t("inventory.errors.updating", { + message: JSON.stringify(r2.errors), + }), + }); + } + } await Promise.all( remainingValues.billlines diff --git a/client/src/components/bill-form/bill-form.component.jsx b/client/src/components/bill-form/bill-form.component.jsx index f57c56819..a9eb07443 100644 --- a/client/src/components/bill-form/bill-form.component.jsx +++ b/client/src/components/bill-form/bill-form.component.jsx @@ -48,6 +48,7 @@ export function BillFormComponent({ disableInvNumber, job, loadOutstandingReturns, + loadInventory, }) { const { t } = useTranslation(); const client = useApolloClient(); @@ -61,6 +62,7 @@ export function BillFormComponent({ setDiscount(opt.discount); opt && + !billEdit && loadOutstandingReturns({ variables: { jobId: form.getFieldValue("jobid"), @@ -86,7 +88,7 @@ export function BillFormComponent({ const jobId = form.getFieldValue("jobid"); if (jobId) { loadLines({ variables: { id: jobId } }); - if (form.getFieldValue("is_credit_memo") && vendorId) { + if (form.getFieldValue("is_credit_memo") && vendorId && !billEdit) { loadOutstandingReturns({ variables: { jobId: jobId, @@ -95,12 +97,19 @@ export function BillFormComponent({ }); } } + + if (vendorId === bodyshop.inhousevendorid && !billEdit) { + loadInventory(); + } }, [ form, + billEdit, loadOutstandingReturns, + loadInventory, setDiscount, vendorAutoCompleteOptions, loadLines, + bodyshop.inhousevendorid, ]); return ( @@ -425,6 +434,7 @@ export function BillFormComponent({ form={form} responsibilityCenters={responsibilityCenters} disabled={disabled} + billEdit={billEdit} /> )} diff --git a/client/src/components/bill-form/bill-form.container.jsx b/client/src/components/bill-form/bill-form.container.jsx index 4fe056667..358bb7f38 100644 --- a/client/src/components/bill-form/bill-form.container.jsx +++ b/client/src/components/bill-form/bill-form.container.jsx @@ -8,6 +8,9 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import BillFormComponent from "./bill-form.component"; import BillCmdReturnsTableComponent from "../bill-cm-returns-table/bill-cm-returns-table.component"; import { QUERY_UNRECEIVED_LINES } from "../../graphql/parts-orders.queries"; +import BillInventoryTable from "../bill-inventory-table/bill-inventory-table.component"; +import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries"; +import { useTreatments } from "@splitsoftware/splitio-react"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -20,6 +23,12 @@ export function BillFormContainer({ disabled, disableInvNumber, }) { + const { Simple_Inventory } = useTreatments( + ["Simple_Inventory"], + {}, + bodyshop && bodyshop.imexshopid + ); + const { data: VendorAutoCompleteData } = useQuery( SEARCH_VENDOR_AUTOCOMPLETE, { fetchPolicy: "network-only", nextFetchPolicy: "network-only" } @@ -31,6 +40,8 @@ export function BillFormContainer({ const [loadOutstandingReturns, { loading: returnLoading, data: returnData }] = useLazyQuery(QUERY_UNRECEIVED_LINES); + const [loadInventory, { loading: inventoryLoading, data: inventoryData }] = + useLazyQuery(QUERY_OUTSTANDING_INVENTORY); return ( <> @@ -47,6 +58,7 @@ export function BillFormContainer({ responsibilityCenters={bodyshop.md_responsibility_centers || null} disableInvNumber={disableInvNumber} loadOutstandingReturns={loadOutstandingReturns} + loadInventory={loadInventory} /> {!billEdit && ( )} + {Simple_Inventory.treatment === "on" && ( + + )} ); } diff --git a/client/src/components/bill-form/bill-form.lines.component.jsx b/client/src/components/bill-form/bill-form.lines.component.jsx index b8a1b5c9d..30aebdfe8 100644 --- a/client/src/components/bill-form/bill-form.lines.component.jsx +++ b/client/src/components/bill-form/bill-form.lines.component.jsx @@ -8,7 +8,7 @@ import { Space, Switch, Table, - Tooltip + Tooltip, } from "antd"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -18,6 +18,8 @@ import { selectBodyshop } from "../../redux/user/user.selectors"; import CiecaSelect from "../../utils/Ciecaselect"; import BillLineSearchSelect from "../bill-line-search-select/bill-line-search-select.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component"; +import BilllineAddInventory from "../billline-add-inventory/billline-add-inventory.component"; +import { useTreatments } from "@splitsoftware/splitio-react"; const mapStateToProps = createStructuredSelector({ //currentUser: selectCurrentUser @@ -34,10 +36,16 @@ export function BillEnterModalLinesComponent({ discount, form, responsibilityCenters, + billEdit, + billid, }) { const { t } = useTranslation(); const { setFieldsValue, getFieldsValue, getFieldValue } = form; - + const { Simple_Inventory } = useTreatments( + ["Simple_Inventory"], + {}, + bodyshop && bodyshop.imexshopid + ); const columns = (remove) => { return [ { @@ -142,6 +150,24 @@ export function BillEnterModalLinesComponent({ required: true, //message: t("general.validation.required"), }, + ({ getFieldValue }) => ({ + validator(rule, value) { + if ( + value && + getFieldValue("billlines")[field.fieldKey]?.inventories + ?.length > value + ) { + return Promise.reject( + t("bills.validation.inventoryquantity", { + number: + getFieldValue("billlines")[field.fieldKey] + ?.inventories?.length, + }) + ); + } + return Promise.resolve(); + }, + }), ], }; }, @@ -477,9 +503,33 @@ export function BillEnterModalLinesComponent({ dataIndex: "actions", render: (text, record) => ( - + + {() => ( + + + {Simple_Inventory.treatment === "on" && ( + + )} + + )} + ), }, ]; diff --git a/client/src/components/bill-inventory-table/bill-inventory-table.component.jsx b/client/src/components/bill-inventory-table/bill-inventory-table.component.jsx new file mode 100644 index 000000000..30f35aa1f --- /dev/null +++ b/client/src/components/bill-inventory-table/bill-inventory-table.component.jsx @@ -0,0 +1,173 @@ +import { Checkbox, Form, Skeleton, Typography } from "antd"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component"; +import "./bill-inventory-table.styles.scss"; + +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import { selectBillEnterModal } from "../../redux/modals/modals.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + billEnterModal: selectBillEnterModal, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect(mapStateToProps, mapDispatchToProps)(BillInventoryTable); + +export function BillInventoryTable({ + billEnterModal, + bodyshop, + form, + billEdit, + inventoryLoading, + inventoryData, +}) { + const { t } = useTranslation(); + + useEffect(() => { + if (inventoryData && inventoryData.inventory) { + form.setFieldsValue({ + inventory: billEnterModal.context.consumeinventoryid + ? inventoryData.inventory.map((i) => { + if (i.id === billEnterModal.context.consumeinventoryid) + i.consumefrominventory = true; + return i; + }) + : inventoryData.inventory, + }); + } + }, [inventoryData, form, billEnterModal.context.consumeinventoryid]); + + return ( + prev.vendorid !== cur.vendorid} + noStyle + > + {() => { + const is_inhouse = + form.getFieldValue("vendorid") === bodyshop.inhousevendorid; + + if (!is_inhouse || billEdit) { + return null; + } + + if (inventoryLoading) return ; + + return ( + + {(fields, { add, remove, move }) => { + return ( + <> + + {t("inventory.labels.inventory")} + + + + + + + + + + + + + + + {fields.map((field, index) => ( + + + + + + + + + + + + ))} + +
{t("billlines.fields.line_desc")}{t("vendors.fields.name")}{t("billlines.fields.quantity")}{t("billlines.fields.actual_price")}{t("billlines.fields.actual_cost")}{t("inventory.fields.comment")}{t("inventory.actions.consumefrominventory")}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); + }} +
+ ); + }} +
+ ); +} diff --git a/client/src/components/bill-inventory-table/bill-inventory-table.styles.scss b/client/src/components/bill-inventory-table/bill-inventory-table.styles.scss new file mode 100644 index 000000000..67bc0543b --- /dev/null +++ b/client/src/components/bill-inventory-table/bill-inventory-table.styles.scss @@ -0,0 +1,19 @@ +.bill-inventory-table { + table-layout: fixed; + width: 100%; + + th, + td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #ddd; + + .ant-form-item { + margin-bottom: 0px !important; + } + } + + tr:hover { + background-color: #f5f5f5; + } +} \ No newline at end of file diff --git a/client/src/components/bill-mark-exported-button/bill-mark-exported-button.component.jsx b/client/src/components/bill-mark-exported-button/bill-mark-exported-button.component.jsx index d81244098..cd9d0b3e0 100644 --- a/client/src/components/bill-mark-exported-button/bill-mark-exported-button.component.jsx +++ b/client/src/components/bill-mark-exported-button/bill-mark-exported-button.component.jsx @@ -9,11 +9,14 @@ import { createStructuredSelector } from "reselect"; import { selectAuthLevel, selectBodyshop, + selectCurrentUser, } from "../../redux/user/user.selectors"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component"; +import { INSERT_EXPORT_LOG } from "../../graphql/accounting.queries"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, authLevel: selectAuthLevel, + currentUser: selectCurrentUser, }); const mapDispatchToProps = (dispatch) => ({ //setUserLanguage: language => dispatch(setUserLanguage(language)) @@ -24,9 +27,15 @@ export default connect( mapDispatchToProps )(BillMarkExportedButton); -export function BillMarkExportedButton({ bodyshop, authLevel, bill }) { +export function BillMarkExportedButton({ + currentUser, + bodyshop, + authLevel, + bill, +}) { const { t } = useTranslation(); const [loading, setLoading] = useState(false); + const [insertExportLog] = useMutation(INSERT_EXPORT_LOG); const [updateBill] = useMutation(gql` mutation UPDATE_BILL($billId: uuid!) { @@ -46,6 +55,20 @@ export function BillMarkExportedButton({ bodyshop, authLevel, bill }) { variables: { billId: bill.id }, }); + await insertExportLog({ + variables: { + logs: [ + { + bodyshopid: bodyshop.id, + billid: bill.id, + successful: true, + message: JSON.stringify([t("general.labels.markedexported")]), + useremail: currentUser.email, + }, + ], + }, + }); + if (!result.errors) { notification["success"]({ message: t("bills.successes.markexported"), @@ -69,11 +92,7 @@ export function BillMarkExportedButton({ bodyshop, authLevel, bill }) { if (hasAccess) return ( - ); diff --git a/client/src/components/billline-add-inventory/billline-add-inventory.component.jsx b/client/src/components/billline-add-inventory/billline-add-inventory.component.jsx new file mode 100644 index 000000000..1fea48f5b --- /dev/null +++ b/client/src/components/billline-add-inventory/billline-add-inventory.component.jsx @@ -0,0 +1,155 @@ +import { FileAddFilled } from "@ant-design/icons"; +import { useMutation } from "@apollo/client"; +import { Button, notification, Tooltip } from "antd"; +import { t } from "i18next"; +import moment from "moment"; +import React, { useState } from "react"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { INSERT_INVENTORY_AND_CREDIT } from "../../graphql/inventory.queries"; +import { + selectBodyshop, + selectCurrentUser, +} from "../../redux/user/user.selectors"; +import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; +import queryString from "query-string"; +import { useLocation } from "react-router-dom"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(BilllineAddInventory); + +export function BilllineAddInventory({ + currentUser, + bodyshop, + billline, + disabled, + jobid, +}) { + const [loading, setLoading] = useState(false); + const { billid } = queryString.parse(useLocation().search); + + const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT); + + const addToInventory = async () => { + setLoading(true); + + //Check to make sure there are no existing items already in the inventory. + + const cm = { + vendorid: bodyshop.inhousevendorid, + invoice_number: "ih", + jobid: jobid, + isinhouse: true, + is_credit_memo: true, + date: moment().format("YYYY-MM-DD"), + federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate, + state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate, + local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate, + total: 0, + billlines: [ + { + actual_price: billline.actual_price, + actual_cost: billline.actual_cost, + quantity: billline.quantity, + line_desc: billline.line_desc, + cost_center: billline.cost_center, + deductedfromlbr: billline.deductedfromlbr, + applicable_taxes: { + local: billline.applicable_taxes.local, + state: billline.applicable_taxes.state, + federal: billline.applicable_taxes.federal, + }, + }, + ], + }; + + cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100; + + const insertResult = await insertInventoryLine({ + variables: { + joblineId: + billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line. + //Unfortunately, we can't send null as the GQL syntax validation fails. + joblineStatus: bodyshop.md_order_statuses.default_returned, + inv: { + shopid: bodyshop.id, + billlineid: billline.id, + actual_price: billline.actual_price, + actual_cost: billline.actual_cost, + quantity: billline.quantity, + line_desc: billline.line_desc, + }, + cm: { ...cm, billlines: { data: cm.billlines } }, //Fix structure for apollo insert. + pol: { + returnfrombill: billid, + vendorid: bodyshop.inhousevendorid, + deliver_by: moment().format("YYYY-MM-DD"), + parts_order_lines: { + data: [ + { + line_desc: billline.line_desc, + + act_price: billline.actual_price, + cost: billline.actual_cost, + quantity: billline.quantity, + job_line_id: + billline.joblineid === "noline" ? null : billline.joblineid, + part_type: billline.jobline && billline.jobline.part_type, + cm_received: true, + }, + ], + }, + order_date: "2022-06-01", + orderedby: currentUser.email, + jobid: jobid, + user_email: currentUser.email, + return: true, + status: "Ordered", + }, + }, + refetchQueries: ["QUERY_BILL_BY_PK"], + }); + + if (!insertResult.errors) { + notification.open({ + type: "success", + message: t("inventory.successes.inserted"), + }); + } else { + notification.open({ + type: "error", + message: t("inventory.errors.inserting", { + error: JSON.stringify(insertResult.errors), + }), + }); + } + + setLoading(false); + }; + + return ( + + + + ); +} diff --git a/client/src/components/courtesy-cars-list/courtesy-cars-list.component.jsx b/client/src/components/courtesy-cars-list/courtesy-cars-list.component.jsx index a488368b9..62f6d1119 100644 --- a/client/src/components/courtesy-cars-list/courtesy-cars-list.component.jsx +++ b/client/src/components/courtesy-cars-list/courtesy-cars-list.component.jsx @@ -1,12 +1,12 @@ -import { SyncOutlined } from "@ant-design/icons"; -import { Button, Card, Input, Space, Table } from "antd"; +import { SyncOutlined, WarningFilled } from "@ant-design/icons"; +import { Button, Card, Input, Space, Table, Tooltip } from "antd"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { DateTimeFormatter } from "../../utils/DateFormatter"; import { alphaSort } from "../../utils/sorters"; import { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component"; - +import moment from "moment"; export default function CourtesyCarsList({ loading, courtesycars, refetch }) { const [state, setState] = useState({ sortedInfo: {}, @@ -56,7 +56,25 @@ export default function CourtesyCarsList({ loading, courtesycars, refetch }) { onFilter: (value, record) => value.includes(record.status), sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order, - render: (text, record) => t(record.status), + render: (text, record) => { + const { nextservicedate, nextservicekm, mileage } = record; + + const mileageOver = nextservicekm <= mileage; + + const dueForService = + nextservicedate && moment(nextservicedate).isBefore(moment()); + + return ( + + {t(record.status)} + {(mileageOver || dueForService) && ( + + + + )} + + ); + }, }, { title: t("courtesycars.fields.year"), diff --git a/client/src/components/email-overlay/email-overlay.component.jsx b/client/src/components/email-overlay/email-overlay.component.jsx index ef9ab2772..d954cdcc3 100644 --- a/client/src/components/email-overlay/email-overlay.component.jsx +++ b/client/src/components/email-overlay/email-overlay.component.jsx @@ -139,7 +139,9 @@ export function EmailOverlayComponent({ {t("emails.labels.preview")} - {t("emails.labels.pdfcopywillbeattached")} + {bodyshop.attach_pdf_to_email && ( + {t("emails.labels.pdfcopywillbeattached")} + )} {() => { diff --git a/client/src/components/header/header.component.jsx b/client/src/components/header/header.component.jsx index 2a4e9698e..85aa0c200 100644 --- a/client/src/components/header/header.component.jsx +++ b/client/src/components/header/header.component.jsx @@ -1,3 +1,4 @@ +import { useTreatments } from "@splitsoftware/splitio-react"; import Icon, { BankFilled, BarChartOutlined, @@ -83,6 +84,12 @@ function Header({ setReportCenterContext, recentItems, }) { + const { Simple_Inventory } = useTreatments( + ["Simple_Inventory"], + {}, + bodyshop && bodyshop.imexshopid + ); + const { t } = useTranslation(); return ( @@ -199,7 +206,20 @@ function Header({ > {t("menus.header.enterbills")} - + {Simple_Inventory.treatment === "on" && ( + <> + + } + > + + {t("menus.header.inventory")} + + + + )} + }> {t("menus.header.allpayments")} @@ -216,7 +236,6 @@ function Header({ {t("menus.header.enterpayment")} - }> {t("menus.header.timetickets")} @@ -235,7 +254,6 @@ function Header({ {t("menus.header.entertimeticket")} - ({ + setBillEnterContext: (context) => + dispatch(setModalContext({ context: context, modal: "billEnter" })), +}); +export default connect(mapStateToProps, mapDispatchToProps)(InventoryBillRo); +export function InventoryBillRo({ + bodyshop, + setBillEnterContext, + inventoryline, +}) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/client/src/components/inventory-line-delete/inventory-line-delete.component.jsx b/client/src/components/inventory-line-delete/inventory-line-delete.component.jsx new file mode 100644 index 000000000..d31be9384 --- /dev/null +++ b/client/src/components/inventory-line-delete/inventory-line-delete.component.jsx @@ -0,0 +1,67 @@ +import { DeleteFilled } from "@ant-design/icons"; +import { useMutation } from "@apollo/client"; +import { Button, notification, Popconfirm } from "antd"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DELETE_INVENTORY_LINE } from "../../graphql/inventory.queries"; +import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; + +export default function InventoryLineDelete({ + inventoryline, + disabled, + refetch, +}) { + const [loading, setLoading] = useState(false); + const { t } = useTranslation(); + const [deleteInventoryLine] = useMutation(DELETE_INVENTORY_LINE); + + const handleDelete = async () => { + setLoading(true); + const result = await deleteInventoryLine({ + variables: { lineId: inventoryline.id }, + // update(cache, { errors }) { + // cache.modify({ + // fields: { + // inventory(existingInventory, { readField }) { + // console.log(existingInventory); + // return existingInventory.filter( + // (invRef) => inventoryline.id !== readField("id", invRef) + // ); + // }, + // }, + // }); + // }, + }); + + if (!!!result.errors) { + notification["success"]({ message: t("inventory.successes.deleted") }); + } else { + //Check if it's an fkey violation. + + notification["error"]({ + message: t("bills.errors.deleting", { + error: JSON.stringify(result.errors), + }), + }); + } + if (refetch) refetch(); + setLoading(false); + }; + + return ( + }> + + + + + ); +} diff --git a/client/src/components/inventory-list/inventory-list.component.jsx b/client/src/components/inventory-list/inventory-list.component.jsx new file mode 100644 index 000000000..9baa66de0 --- /dev/null +++ b/client/src/components/inventory-list/inventory-list.component.jsx @@ -0,0 +1,228 @@ +import { EditFilled, SyncOutlined, FileAddFilled } from "@ant-design/icons"; +import { Button, Card, Input, Space, Table, Typography } from "antd"; +import queryString from "query-string"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { Link, useHistory, useLocation } from "react-router-dom"; +import { createStructuredSelector } from "reselect"; +import { setModalContext } from "../../redux/modals/modals.actions"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import CurrencyFormatter from "../../utils/CurrencyFormatter"; +import InventoryBillRo from "../inventory-bill-ro/inventory-bill-ro.component"; +import InventoryLineDelete from "../inventory-line-delete/inventory-line-delete.component"; +const mapStateToProps = createStructuredSelector({ + //currentUser: selectCurrentUser + bodyshop: selectBodyshop, +}); +const mapDispatchToProps = (dispatch) => ({ + setInventoryUpsertContext: (context) => + dispatch(setModalContext({ context: context, modal: "inventoryUpsert" })), +}); + +export function JobsList({ + bodyshop, + refetch, + loading, + jobs, + total, + setInventoryUpsertContext, +}) { + const search = queryString.parse(useLocation().search); + const { page, sortcolumn, sortorder } = search; + const history = useHistory(); + + const { t } = useTranslation(); + const columns = [ + { + title: t("billlines.fields.line_desc"), + dataIndex: "line_desc", + key: "line_desc", + + sorter: true, //(a, b) => alphaSort(a.line_desc, b.line_desc), + sortOrder: sortcolumn === "line_desc" && sortorder, + render: (text, record) => + record.billline?.bill?.job ? ( +
+
{text}
+ {`(${record.billline?.bill?.job?.v_model_yr} ${record.billline?.bill?.job?.v_make_desc} ${record.billline?.bill?.job?.v_model_desc})`} +
+ ) : ( + text + ), + }, + { + title: t("inventory.labels.frombillinvoicenumber"), + dataIndex: "vendorname", + key: "vendorname", + ellipsis: true, + //sorter: true, // (a, b) => alphaSort(a.ownr_ln, b.ownr_ln), + + //sortOrder: sortcolumn === "ownr_ln" && sortorder, + render: (text, record) => + ( + (record.billline?.bill?.invoice_number || "") + + " " + + (record.manualinvoicenumber || "") + ).trim(), + }, + { + title: t("inventory.labels.fromvendor"), + dataIndex: "vendorname", + key: "vendorname", + ellipsis: true, + //sorter: true, // (a, b) => alphaSort(a.ownr_ln, b.ownr_ln), + + //sortOrder: sortcolumn === "ownr_ln" && sortorder, + render: (text, record) => + ( + (record.billline?.bill?.vendor?.name || "") + + " " + + (record.manualvendor || "") + ).trim(), + }, + { + title: t("billlines.fields.actual_price"), + dataIndex: "actual_price", + key: "actual_price", + + render: (text, record) => ( + {record.actual_price} + ), + }, + { + title: t("billlines.fields.actual_cost"), + dataIndex: "actual_cost", + key: "actual_cost", + + render: (text, record) => ( + {record.actual_cost} + ), + }, + { + title: t("inventory.fields.comment"), + dataIndex: "comment", + key: "comment", + }, + { + title: t("inventory.labels.consumedbyjob"), + dataIndex: "consumedbyjob", + key: "consumedbyjob", + + ellipsis: true, + render: (text, record) => + record.bill?.job?.ro_number ? ( + + {record.bill?.job?.ro_number} + + ) : ( + + ), + }, + { + title: t("general.labels.actions"), + dataIndex: "actions", + key: "actions", + + ellipsis: true, + render: (text, record) => ( + + + + + ), + }, + ]; + + const handleTableChange = (pagination, filters, sorter) => { + search.page = pagination.current; + search.sortcolumn = sorter.column && sorter.column.key; + search.sortorder = sorter.order; + history.push({ search: queryString.stringify(search) }); + }; + + return ( + + {search.search && ( + <> + + {t("general.labels.searchresults", { search: search.search })} + + + + )} + + + + + { + search.search = value; + history.push({ search: queryString.stringify(search) }); + }} + enterButton + /> + + } + > + + + ); +} +export default connect(mapStateToProps, mapDispatchToProps)(JobsList); diff --git a/client/src/components/inventory-list/inventory-list.container.jsx b/client/src/components/inventory-list/inventory-list.container.jsx new file mode 100644 index 000000000..bb265060f --- /dev/null +++ b/client/src/components/inventory-list/inventory-list.container.jsx @@ -0,0 +1,64 @@ +import { useQuery } from "@apollo/client"; +import queryString from "query-string"; +import React from "react"; +import { connect } from "react-redux"; +import { useLocation } from "react-router-dom"; +import { createStructuredSelector } from "reselect"; +import { QUERY_INVENTORY_PAGINATED } from "../../graphql/inventory.queries"; +import { + setBreadcrumbs, + setSelectedHeader, +} from "../../redux/application/application.actions"; +import AlertComponent from "../alert/alert.component"; +import InventoryListPaginated from "./inventory-list.component"; + +const mapStateToProps = createStructuredSelector({ + //bodyshop: selectBodyshop, +}); + +const mapDispatchToProps = (dispatch) => ({ + setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), + setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), +}); + +export function InventoryList({ setBreadcrumbs, setSelectedHeader }) { + const searchParams = queryString.parse(useLocation().search); + const { page, sortcolumn, sortorder, search, showall } = searchParams; + + const { loading, error, data, refetch } = useQuery( + QUERY_INVENTORY_PAGINATED, + { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + variables: { + search: search || "", + offset: page ? (page - 1) * 25 : 0, + limit: 25, + consumedIsNull: showall === "true" ? null : true, + order: [ + { + [sortcolumn || "created_at"]: + sortorder && sortorder !== "false" + ? sortorder === "descend" + ? "desc" + : "asc" + : "desc", + }, + ], + }, + } + ); + + if (error) return ; + return ( + + ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(InventoryList); diff --git a/client/src/components/inventory-upsert-modal/inventory-upsert-modal.component.jsx b/client/src/components/inventory-upsert-modal/inventory-upsert-modal.component.jsx new file mode 100644 index 000000000..7d5d28ebb --- /dev/null +++ b/client/src/components/inventory-upsert-modal/inventory-upsert-modal.component.jsx @@ -0,0 +1,68 @@ +import { Form, Input, Space } from "antd"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectInventoryUpsert } from "../../redux/modals/modals.selectors"; +import FormItemCurrency from "../form-items-formatted/currency-form-item.component"; +const mapStateToProps = createStructuredSelector({ + inventoryUpsertModal: selectInventoryUpsert, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(NoteUpsertModalComponent); + +export function NoteUpsertModalComponent({ form, inventoryUpsertModal }) { + const { t } = useTranslation(); + const { existingInventory } = inventoryUpsertModal.context; + + return ( + + + + + + + + + {!existingInventory && ( + <> + + + + + + + + + + + + + + )} + + ); +} diff --git a/client/src/components/inventory-upsert-modal/inventory-upsert-modal.container.jsx b/client/src/components/inventory-upsert-modal/inventory-upsert-modal.container.jsx new file mode 100644 index 000000000..715131b02 --- /dev/null +++ b/client/src/components/inventory-upsert-modal/inventory-upsert-modal.container.jsx @@ -0,0 +1,126 @@ +import { useMutation } from "@apollo/client"; +import { Form, Modal, notification } from "antd"; +import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { logImEXEvent } from "../../firebase/firebase.utils"; +import { + INSERT_INVENTORY_LINE, + UPDATE_INVENTORY_LINE, +} from "../../graphql/inventory.queries"; +import { toggleModalVisible } from "../../redux/modals/modals.actions"; +import { selectInventoryUpsert } from "../../redux/modals/modals.selectors"; +import { + selectBodyshop, + selectCurrentUser, +} from "../../redux/user/user.selectors"; +import InventoryUpsertModal from "./inventory-upsert-modal.component"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, + currentUser: selectCurrentUser, + inventoryUpsertModal: selectInventoryUpsert, +}); +const mapDispatchToProps = (dispatch) => ({ + toggleModalVisible: () => dispatch(toggleModalVisible("inventoryUpsert")), +}); + +export function InventoryUpsertModalContainer({ + currentUser, + bodyshop, + inventoryUpsertModal, + toggleModalVisible, +}) { + const { t } = useTranslation(); + const [insertInventory] = useMutation(INSERT_INVENTORY_LINE); + const [updateInventoryLine] = useMutation(UPDATE_INVENTORY_LINE); + + const { visible, context, actions } = inventoryUpsertModal; + const { existingInventory } = context; + const { refetch } = actions; + + const [form] = Form.useForm(); + + useEffect(() => { + //Required to prevent infinite looping. + if (existingInventory && visible) { + form.setFieldsValue(existingInventory); + } else if (!existingInventory && visible) { + form.resetFields(); + } + }, [existingInventory, form, visible]); + + const handleFinish = async (formValues) => { + const values = formValues; + + if (existingInventory) { + logImEXEvent("inventory_update"); + + updateInventoryLine({ + variables: { + inventoryId: existingInventory.id, + inventoryItem: values, + }, + }).then((r) => { + notification["success"]({ + message: t("inventory.successes.updated"), + }); + }); + // if (refetch) refetch(); + toggleModalVisible(); + } else { + logImEXEvent("inventory_insert"); + + await insertInventory({ + variables: { + inventoryItem: { shopid: bodyshop.id, ...values }, + }, + update(cache, { data }) { + cache.modify({ + fields: { + inventory(existingInv) { + return [...existingInv, data.insert_inventory_one]; + }, + }, + }); + }, + }); + + if (refetch) refetch(); + form.resetFields(); + toggleModalVisible(); + notification["success"]({ + message: t("inventory.successes.inserted"), + }); + } + }; + + return ( + { + form.submit(); + }} + onCancel={() => { + toggleModalVisible(); + }} + destroyOnClose + > +
+ + +
+ ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps +)(InventoryUpsertModalContainer); diff --git a/client/src/components/job-bills-total/job-bills-total.component.jsx b/client/src/components/job-bills-total/job-bills-total.component.jsx index b1d4f33f5..701ebe1c6 100644 --- a/client/src/components/job-bills-total/job-bills-total.component.jsx +++ b/client/src/components/job-bills-total/job-bills-total.component.jsx @@ -86,6 +86,7 @@ export default function JobBillsTotalComponent({ const totalPartsSublet = Dinero(totals.parts.parts.total) .add(Dinero(totals.parts.sublets.total)) + .add(Dinero(totals.additional.shipping)) .add(Dinero(totals.additional.towing)); const discrepancy = totalPartsSublet.subtract(billTotals); diff --git a/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx b/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx index 9ead0530c..fe394f301 100644 --- a/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx +++ b/client/src/components/job-parts-queue-count/job-parts-queue-count.component.jsx @@ -11,7 +11,7 @@ const mapDispatchToProps = (dispatch) => ({ }); export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount); -export function JobPartsQueueCount({ bodyshop, parts }) { +export function JobPartsQueueCount({ bodyshop, parts, style }) { const partsStatus = useMemo(() => { if (!parts) return null; return parts.reduce( @@ -36,7 +36,7 @@ export function JobPartsQueueCount({ bodyshop, parts }) { if (!parts) return null; return ( - +
{partsStatus.total} diff --git a/client/src/components/job-reconciliation-modal/job-reconciliation-modal.component.jsx b/client/src/components/job-reconciliation-modal/job-reconciliation-modal.component.jsx index cde3abc6c..8c83a504a 100644 --- a/client/src/components/job-reconciliation-modal/job-reconciliation-modal.component.jsx +++ b/client/src/components/job-reconciliation-modal/job-reconciliation-modal.component.jsx @@ -22,7 +22,8 @@ export default function JobReconciliationModalComponent({ job, bills }) { (j.part_type !== null && j.part_type !== "PAE") || (j.line_desc && j.line_desc.toLowerCase().includes("towing") && - j.lbr_op === "OP13") + j.lbr_op === "OP13") || + j.db_ref === "936004" //ADD SHIPPING LINE. ); return ( diff --git a/client/src/components/job-reconciliation-totals/job-reconciliation-totals.utility.js b/client/src/components/job-reconciliation-totals/job-reconciliation-totals.utility.js index 119878890..6a6e1a0f6 100644 --- a/client/src/components/job-reconciliation-totals/job-reconciliation-totals.utility.js +++ b/client/src/components/job-reconciliation-totals/job-reconciliation-totals.utility.js @@ -1,5 +1,6 @@ import i18next from "i18next"; import _ from "lodash"; + export const reconcileByAssocLine = ( jobLines, jobLineState, @@ -73,7 +74,12 @@ export const reconcileByPrice = ( jobLines.forEach((jl) => { const matchingBillLineIds = billLines - .filter((bl) => bl.actual_price === jl.act_price && bl.quantity === jl.part_qty && !jl.removed) + .filter( + (bl) => + bl.actual_price === jl.act_price && + bl.quantity === jl.part_qty && + !jl.removed + ) .map((bl) => bl.id); if (matchingBillLineIds.length > 1) { diff --git a/client/src/components/job-scoreboard-add-button/job-scoreboard-add-button.component.jsx b/client/src/components/job-scoreboard-add-button/job-scoreboard-add-button.component.jsx index 4ab815d87..f72457ea9 100644 --- a/client/src/components/job-scoreboard-add-button/job-scoreboard-add-button.component.jsx +++ b/client/src/components/job-scoreboard-add-button/job-scoreboard-add-button.component.jsx @@ -169,7 +169,7 @@ export default function ScoreboardAddButton({ }; return ( - + - - } + )} + + + } + > +
setVisible(true)} + style={{ + height: "19px", + }} + className={className} > -
setVisible(true)} - style={{ - height: "19px", - }} - className={className} - > - {record[field]} -
- -
+ {record[field]} + + ); } diff --git a/client/src/components/rbac-wrapper/rbac-defaults.js b/client/src/components/rbac-wrapper/rbac-defaults.js index 089e8954e..1c2a779c4 100644 --- a/client/src/components/rbac-wrapper/rbac-defaults.js +++ b/client/src/components/rbac-wrapper/rbac-defaults.js @@ -25,7 +25,7 @@ const ret = { "jobs:detail": 1, "jobs:partsqueue": 4, "jobs:checklist-view": 2, - + "jobs:list-ready": 1, "bills:enter": 2, "bills:view": 2, "bills:list": 2, @@ -66,5 +66,8 @@ const ret = { "timetickets:shiftedit": 5, "users:editaccess": 4, + + "inventory:list": 1, + "inventory:delete": 2, }; export default ret; diff --git a/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx b/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx index 3a74409f2..a8488e9dd 100644 --- a/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx +++ b/client/src/components/scoreboard-entry-edit/scoreboard-entry-edit.component.jsx @@ -26,7 +26,7 @@ export default function ScoreboardEntryEdit({ entry }) { return; } else { notification["success"]({ - message: t("scoredboard.successes.updated"), + message: t("scoreboard.successes.updated"), }); setVisible(false); } diff --git a/client/src/components/scoreboard-targets-table/scoreboard-targets-table.util.js b/client/src/components/scoreboard-targets-table/scoreboard-targets-table.util.js index 97f1813b9..9f925a1a3 100644 --- a/client/src/components/scoreboard-targets-table/scoreboard-targets-table.util.js +++ b/client/src/components/scoreboard-targets-table/scoreboard-targets-table.util.js @@ -47,3 +47,15 @@ export const ListOfDaysInCurrentMonth = () => { days.push(dateEnd.format("YYYY-MM-DD")); return days; }; + +export const ListDaysBetween = ({ start, end }) => { + const days = []; + const dateStart = moment(start); + const dateEnd = moment(end); + while (dateEnd.diff(dateStart, "days") > 0) { + days.push(dateStart.format("YYYY-MM-DD")); + dateStart.add(1, "days"); + } + days.push(dateEnd.format("YYYY-MM-DD")); + return days; +}; diff --git a/client/src/components/scoreboard-timetickets/scoreboard-timetickets.bar.component.jsx b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.bar.component.jsx new file mode 100644 index 000000000..fbf6da6d2 --- /dev/null +++ b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.bar.component.jsx @@ -0,0 +1,79 @@ +import { Card } from "antd"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { + Bar, + CartesianGrid, + ComposedChart, + LabelList, + Legend, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +import TimeTicketsDatesSelector from "../ticket-tickets-dates-selector/time-tickets-dates-selector.component"; +const graphProps = { + strokeWidth: 3, +}; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); + +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ScoreboardTicketsBar); + +export function ScoreboardTicketsBar({ data, bodyshop }) { + const { t } = useTranslation(); + return ( + } + > + + + + + + + + + {data && + data.employees.map((e, idx) => ( + + + + ))} + + + + ); +} diff --git a/client/src/components/scoreboard-timetickets/scoreboard-timetickets.component.jsx b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.component.jsx new file mode 100644 index 000000000..35c8e2b8c --- /dev/null +++ b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.component.jsx @@ -0,0 +1,301 @@ +import { useQuery } from "@apollo/client"; +import { Col, Row } from "antd"; +import _ from "lodash"; +import moment from "moment"; +import queryString from "query-string"; +import React, { useMemo } from "react"; +import { useLocation } from "react-router-dom"; +import { QUERY_TIME_TICKETS_IN_RANGE_SB } from "../../graphql/timetickets.queries"; +import AlertComponent from "../alert/alert.component"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; +import * as Utils from "../scoreboard-targets-table/scoreboard-targets-table.util"; +import ScoreboardTicketsBar from "./scoreboard-timetickets.bar.component"; +import ScoreboardTicketsStats from "./scoreboard-timetickets.stats.component"; + +export default function ScoreboardTimeTickets() { + const searchParams = queryString.parse(useLocation().search); + const { start, end } = searchParams; + const startDate = start + ? moment(start) + : moment().startOf("week").subtract(7, "days"); + const endDate = end ? moment(end) : moment().endOf("week"); + + const fixedPeriods = useMemo(() => { + const endOfThisMonth = moment().endOf("month"); + const startofthisMonth = moment().startOf("month"); + + const endOfLastmonth = moment().subtract(1, "month").endOf("month"); + const startOfLastmonth = moment().subtract(1, "month").startOf("month"); + + const endOfThisWeek = moment().endOf("week"); + const startOfThisWeek = moment().startOf("week"); + + const endOfLastWeek = moment().subtract(1, "week").endOf("week"); + const startOfLastWeek = moment().subtract(1, "week").startOf("week"); + + const allDates = [ + endOfThisMonth, + startofthisMonth, + endOfLastmonth, + startOfLastmonth, + endOfThisWeek, + startOfThisWeek, + endOfLastWeek, + startOfLastWeek, + ]; + const start = moment.min(allDates); + const end = moment.max(allDates); + return { + start, + end, + endOfThisMonth, + startofthisMonth, + endOfLastmonth, + startOfLastmonth, + endOfThisWeek, + startOfThisWeek, + endOfLastWeek, + startOfLastWeek, + }; + }, []); + + const { loading, error, data } = useQuery(QUERY_TIME_TICKETS_IN_RANGE_SB, { + variables: { + start: startDate.format("YYYY-MM-DD"), + end: endDate.format("YYYY-MM-DD"), + fixedStart: fixedPeriods.start.format("YYYY-MM-DD"), + fixedEnd: fixedPeriods.end.format("YYYY-MM-DD"), + }, + fetchPolicy: "network-only", + nextFetchPolicy: "network-only", + pollInterval: 60000, + skip: !fixedPeriods, + }); + + const calculatedData = useMemo(() => { + if (!data) return []; + const ret = { + totalThisWeek: 0, + totalLastWeek: 0, + totalThisMonth: 0, + totalLastMonth: 0, + totalOverPeriod: 0, + employees: {}, + }; + data.fixedperiod.forEach((ticket) => { + const ticketDate = moment(ticket.date); + + if (!ret.employees[ticket.employee.employee_number]) { + ret.employees[ticket.employee.employee_number] = { + totalThisWeek: 0, + totalLastWeek: 0, + totalThisMonth: 0, + totalLastMonth: 0, + totalOverPeriod: 0, + }; + } + + if ( + ticketDate.isBetween( + fixedPeriods.startOfThisWeek, + fixedPeriods.endOfThisWeek, + undefined, + "[]" + ) + ) { + ret.totalThisWeek = ret.totalThisWeek + ticket.productivehrs; + ret.employees[ticket.employee.employee_number].totalThisWeek = + ret.employees[ticket.employee.employee_number].totalThisWeek + + ticket.productivehrs; + } else if ( + ticketDate.isBetween( + fixedPeriods.startOfLastWeek, + fixedPeriods.endOfLastWeek, + undefined, + "[]" + ) + ) { + ret.totalLastWeek = ret.totalLastWeek + ticket.productivehrs; + ret.employees[ticket.employee.employee_number].totalLastWeek = + ret.employees[ticket.employee.employee_number].totalLastWeek + + ticket.productivehrs; + } + if ( + ticketDate.isBetween( + fixedPeriods.startofthisMonth, + fixedPeriods.endOfThisMonth, + undefined, + "[]" + ) + ) { + ret.totalThisMonth = ret.totalThisMonth + ticket.productivehrs; + ret.employees[ticket.employee.employee_number].totalThisMonth = + ret.employees[ticket.employee.employee_number].totalThisMonth + + ticket.productivehrs; + } else if ( + ticketDate.isBetween( + fixedPeriods.startOfLastmonth, + fixedPeriods.endOfLastmonth, + undefined, + "[]" + ) + ) { + ret.totalLastMonth = ret.totalLastMonth + ticket.productivehrs; + ret.employees[ticket.employee.employee_number].totalLastMonth = + ret.employees[ticket.employee.employee_number].totalLastMonth + + ticket.productivehrs; + } + }); + + const ticketsGroupedByDate = _.groupBy(data.timetickets, "date"); + const listOfDays = Utils.ListDaysBetween({ + start: startDate, + end: endDate, + }); + + const employees = []; + const ret2 = []; + let totals = { + totalproductive: 0, + totalactual: 0, + employees: {}, + }; + + listOfDays.forEach((day) => { + const r = { + date: moment(day).format("MM/DD"), + actualtotal: 0, + productivetotal: 0, + employees: {}, + }; + + if (ticketsGroupedByDate[day]) { + ticketsGroupedByDate[day].forEach((ticket) => { + r.actualtotal = r.actualtotal + ticket.actualhrs; + r.productivetotal = r.productivetotal + ticket.productivehrs; + totals.totalactual = totals.totalactual + ticket.actualhrs; + totals.totalproductive = + totals.totalproductive + ticket.productivehrs; + + employees.push(ticket.employee.employee_number); + //Add to table data. + ret.employees[ticket.employee.employee_number].totalOverPeriod = + ret.employees[ticket.employee.employee_number].totalOverPeriod + + ticket.productivehrs; + + if (!totals.employees[ticket.employee.employee_number]) + totals.employees[ticket.employee.employee_number] = { + totalactual: 0, + totalproductive: 0, + }; + + if (!r.employees[ticket.employee.employee_number]) + r.employees[ticket.employee.employee_number] = { + actual: 0, + productive: 0, + }; + + //Add to totals. + totals.employees[ticket.employee.employee_number].totalproductive = + totals.employees[ticket.employee.employee_number].totalproductive + + ticket.productivehrs; + + totals.employees[ticket.employee.employee_number].totalactual = + totals.employees[ticket.employee.employee_number].totalactual + + ticket.actualhrs; + //Add to dailys. + r.employees[ticket.employee.employee_number].productive = + r.employees[ticket.employee.employee_number].productive + + ticket.productivehrs; + + r.employees[ticket.employee.employee_number].actual = + r.employees[ticket.employee.employee_number].actual + + ticket.actualhrs; + }); + } + + ret2.push(r); + }); + + return { + fixed: ret, + timeperiod: { + totals, + chartData: ret2, + employees: _.uniq(employees), + colors: getColorArray(employees.length), + }, + }; + }, [fixedPeriods, data, startDate, endDate]); + + if (error) return ; + if (loading) return ; + return ( + +
+ + + + + + + ); +} + +//Include a filter by employee. + +//Hours produced today. +//Hours produced in last 7 days +//Hours produced for time period by day +//Hours produced by employee by day for time period. + +function getColorArray(num) { + return [ + "#3366cc", + "#dc3912", + "#ff9900", + "#109618", + "#990099", + "#0099c6", + "#dd4477", + "#66aa00", + "#b82e2e", + "#316395", + "#3366cc", + "#994499", + "#22aa99", + "#aaaa11", + "#6633cc", + "#e67300", + "#8b0707", + "#651067", + "#329262", + "#5574a6", + "#3b3eac", + "#b77322", + "#16d620", + "#b91383", + "#f4359e", + "#9c5935", + "#a9c413", + "#2a778d", + "#668d1c", + "#bea413", + "#0c5922", + "#743411", + ]; + // var result = []; + // for (var i = 0; i < num; i += 1) { + // var letters = "0123456789ABCDEF".split(""); + // var color = "#"; + // for (var j = 0; j < 6; j += 1) { + // color += letters[Math.floor(Math.random() * 16)]; + // } + // result.push(color); + // } + // return result; +} diff --git a/client/src/components/scoreboard-timetickets/scoreboard-timetickets.stats.component.jsx b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.stats.component.jsx new file mode 100644 index 000000000..3613ee435 --- /dev/null +++ b/client/src/components/scoreboard-timetickets/scoreboard-timetickets.stats.component.jsx @@ -0,0 +1,123 @@ +import { Card, Col, Row, Statistic, Table, Typography } from "antd"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; + +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); + +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ScoreboardTicketsStats); + +export function ScoreboardTicketsStats({ data, bodyshop }) { + const { t } = useTranslation(); + console.log(data); + const columns = [ + { + title: t("employees.fields.employee_number"), + dataIndex: "employee_number", + key: "employee_number", + sorter: (a, b) => a.employee_number - b.employee_number, + }, + { + title: t("scoreboard.labels.thisweek"), + dataIndex: "totalThisWeek", + key: "totalThisWeek", + sorter: (a, b) => a.totalThisWeek - b.totalThisWeek, + }, + { + title: t("scoreboard.labels.lastweek"), + dataIndex: "totalLastWeek", + key: "totalLastWeek", + sorter: (a, b) => a.totalLastWeek - b.totalLastWeek, + }, + { + title: t("scoreboard.labels.thismonth"), + dataIndex: "totalThisMonth", + key: "totalThisMonth", + sorter: (a, b) => a.totalThisMonth - b.totalThisMonth, + }, + { + title: t("scoreboard.labels.lastmonth"), + dataIndex: "totalLastMonth", + key: "totalLastMonth", + sorter: (a, b) => a.totalLastMonth - b.totalLastMonth, + }, + { + title: t("scoreboard.labels.totaloverperiod"), + dataIndex: "totalOverPeriod", + key: "totalOverPeriod", + sorter: (a, b) => a.totalOverPeriod - b.totalOverPeriod, + }, + ]; + + const tableData = data + ? Object.keys(data.employees).map((key) => { + return { employee_number: key, ...data.employees[key] }; + }) + : []; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {t("scoreboard.labels.calendarperiod")} + + + +
+ + + + ); +} diff --git a/client/src/components/shop-info/shop-info.rbac.component.jsx b/client/src/components/shop-info/shop-info.rbac.component.jsx index 3c0f75c88..291369255 100644 --- a/client/src/components/shop-info/shop-info.rbac.component.jsx +++ b/client/src/components/shop-info/shop-info.rbac.component.jsx @@ -3,9 +3,28 @@ import React from "react"; import { useTranslation } from "react-i18next"; import LayoutFormRow from "../layout-form-row/layout-form-row.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; -export default function ShopInfoRbacComponent({ form }) { - const { t } = useTranslation(); +import { useTreatments } from "@splitsoftware/splitio-react"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop } from "../../redux/user/user.selectors"; +const mapStateToProps = createStructuredSelector({ + bodyshop: selectBodyshop, +}); +const mapDispatchToProps = (dispatch) => ({ + //setUserLanguage: language => dispatch(setUserLanguage(language)) +}); +export default connect( + mapStateToProps, + mapDispatchToProps +)(ShopInfoRbacComponent); +export function ShopInfoRbacComponent({ form, bodyshop }) { + const { t } = useTranslation(); + const { Simple_Inventory } = useTreatments( + ["Simple_Inventory"], + {}, + bodyshop && bodyshop.imexshopid + ); return ( @@ -633,6 +652,34 @@ export default function ShopInfoRbacComponent({ form }) { > + {Simple_Inventory.treatment === "on" && ( + <> + + + + + + + + )} ); diff --git a/client/src/components/sign-in-form/sign-in-form.component.jsx b/client/src/components/sign-in-form/sign-in-form.component.jsx index da1c749f7..c964c0dc2 100644 --- a/client/src/components/sign-in-form/sign-in-form.component.jsx +++ b/client/src/components/sign-in-form/sign-in-form.component.jsx @@ -60,7 +60,10 @@ export function SignInComponent({ ({ + setTimeTicketContext: (context) => + dispatch(setModalContext({ context: context, modal: "timeTicket" })), +}); +export function TechClockInContainer({ + setTimeTicketContext, + technician, + bodyshop, +}) { const [form] = Form.useForm(); const [loading, setLoading] = useState(false); const [insertTimeTicket] = useMutation(INSERT_NEW_TIME_TICKET, { @@ -75,6 +83,16 @@ export function TechClockInContainer({ technician, bodyshop }) { title={t("timetickets.labels.clockintojob")} extra={ +