- Merge client update into test-beta

Signed-off-by: Dave Richer <dave@imexsystems.ca>
This commit is contained in:
Dave Richer
2024-01-18 19:20:08 -05:00
696 changed files with 92291 additions and 107075 deletions

View File

@@ -183,6 +183,26 @@ jobs:
- jira/notify - jira/notify
app-beta-build:
docker:
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
steps:
- checkout:
path: ~/repo
- run:
name: Install Dependencies
command: npm i
- run: npm run build
- aws-s3/sync:
from: build
to: "s3://imex-online-beta/"
- jira/notify
test-hasura-migrate: test-hasura-migrate:
docker: docker:
- image: cimg/node:16.15.0 - image: cimg/node:16.15.0
@@ -233,6 +253,27 @@ jobs:
to: "s3://imex-online-test/" to: "s3://imex-online-test/"
- jira/notify - jira/notify
test-app-beta-build:
docker:
- image: cimg/node:18.18.2
resource_class: large
working_directory: ~/repo/client
steps:
- checkout:
path: ~/repo
- run:
name: Install Dependencies
command: npm i
- run: npm run build:test
- aws-s3/sync:
from: build
to: "s3://imex-online-test-beta/"
- jira/notify
admin-app-build: admin-app-build:
docker: docker:
- image: cimg/node:16.15.0 - image: cimg/node:16.15.0
@@ -274,6 +315,10 @@ workflows:
filters: filters:
branches: branches:
only: master only: master
- app-beta-build:
filters:
branches:
only: master-beta
- hasura-migrate: - hasura-migrate:
secret: ${HASURA_PROD_SECRET} secret: ${HASURA_PROD_SECRET}
filters: filters:
@@ -296,6 +341,10 @@ workflows:
filters: filters:
branches: branches:
only: test only: test
- test-app-beta-build:
filters:
branches:
only: test-beta
- test-hasura-migrate: - test-hasura-migrate:
secret: ${HASURA_TEST_SECRET} secret: ${HASURA_TEST_SECRET}
filters: filters:

1
client/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -2,72 +2,89 @@
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
const CracoLessPlugin = require("craco-less"); const CracoLessPlugin = require("craco-less");
const SentryWebpackPlugin = require("@sentry/webpack-plugin"); const SentryWebpackPlugin = require("@sentry/webpack-plugin");
const {convertLegacyToken} = require('@ant-design/compatible/lib');
const {theme} = require('antd/lib');
const {defaultAlgorithm, defaultSeed} = theme;
const mapToken = defaultAlgorithm(defaultSeed);
const v4Token = convertLegacyToken(mapToken);
module.exports = { module.exports = {
plugins: [ plugins: [
{ {
plugin: SentryWebpackPlugin, plugin: SentryWebpackPlugin,
options: { options: {
// sentry-cli configuration // sentry-cli configuration
authToken: authToken:
"6b45b028a02342db97a9a2f92c0959058665443d379d4a3a876430009e744260", "6b45b028a02342db97a9a2f92c0959058665443d379d4a3a876430009e744260",
org: "snapt-software", org: "snapt-software",
project: "rome-online", project: "rome-online",
release: process.env.REACT_APP_GIT_SHA, release: process.env.REACT_APP_GIT_SHA,
// webpack-specific configuration // webpack-specific configuration
include: ".", include: ".",
ignore: ["node_modules", "webpack.config.js"], ignore: ["node_modules", "webpack.config.js"],
},
},
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: {
...(process.env.NODE_ENV === "development"
? { "@primary-color": "#B22234" }
: {
//"@primary-color": "#1DA57A"
}),
// "@primary-color": " #1890ff", // primary color for all components
// "@link-color": "#1890ff", // link color
// "@success-color": "#52c41a", // success state color
// "@warning-color": "#faad14", // warning state color
// "@error-color": "#f5222d", // error state color
// "@font-size-base": "14px", // major text font size
// " @heading-color": "rgba(0, 0, 0, 0.85)", // heading text color
// "@text-color": "rgba(0, 0, 0, 0.65)", // major text color
// "@text-color-secondary": "rgba(0, 0, 0, 0.45)", // secondary text color
// "@disabled-color": "rgba(0, 0, 0, 0.25)", // disable state color
// "@border-radius-base": "2px", // major border radius
// "@border-color-base": "#d9d9d9", // major border color
// "@box-shadow-base":
// "0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),0 9px 28px 8px rgba(0, 0, 0, 0.05); // major shadow for layers }",
}, },
javascriptEnabled: true,
},
}, },
}, {
}, plugin: CracoLessPlugin,
], options: {
webpack: { lessLoaderOptions: {
configure: (webpackConfig) => ({ lessOptions: {
...webpackConfig, modifyVars: {
optimization: { ...v4Token,
...webpackConfig.optimization, // TODO: This will no longer work in AntD 5.0
// Workaround for CircleCI bug caused by the number of CPUs shown ...(process.env.NODE_ENV === "development"
// https://github.com/facebook/create-react-app/issues/8320 ? {"colorPrimary": "#B22234"}
minimizer: webpackConfig.optimization.minimizer.map((item) => { : {
if (item instanceof TerserPlugin) { //"@primary-color": "#1DA57A"
item.options.parallel = 2; }),
} // "@primary-color": " #1890ff", // primary color for all components
// "@link-color": "#1890ff", // link color
// "@success-color": "#52c41a", // success state color
// "@warning-color": "#faad14", // warning state color
// "@error-color": "#f5222d", // error state color
// "@font-size-base": "14px", // major text font size
// " @heading-color": "rgba(0, 0, 0, 0.85)", // heading text color
// "@text-color": "rgba(0, 0, 0, 0.65)", // major text color
// "@text-color-secondary": "rgba(0, 0, 0, 0.45)", // secondary text color
// "@disabled-color": "rgba(0, 0, 0, 0.25)", // disable state color
// "@border-radius-base": "2px", // major border radius
// "@border-color-base": "#d9d9d9", // major border color
// "@box-shadow-base":
// "0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),0 9px 28px 8px rgba(0, 0, 0, 0.05); // major shadow for layers }",
},
javascriptEnabled: true,
},
},
},
},
],
webpack: {
configure: (webpackConfig) => {
return {
...webpackConfig,
// Required for Dev Server
devServer: {
...webpackConfig.devServer,
allowedHosts: 'all',
},
optimization: {
...webpackConfig.optimization,
// Workaround for CircleCI bug caused by the number of CPUs shown
// https://github.com/facebook/create-react-app/issues/8320
minimizer: webpackConfig.optimization.minimizer.map((item) => {
if (item instanceof TerserPlugin) {
item.options.parallel = 2;
}
return item; return item;
}), }),
}, },
}), };
}, },
devtool: "source-map", },
devtool: "source-map",
}; };

17
client/cypress.config.js Normal file
View File

@@ -0,0 +1,17 @@
const { defineConfig } = require('cypress')
module.exports = defineConfig({
experimentalStudio: true,
env: {
FIREBASE_USERNAME: 'cypress@imex.test',
FIREBASE_PASSWORD: 'cypress',
},
e2e: {
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents(on, config) {
return require('./cypress/plugins/index.js')(on, config)
},
baseUrl: 'http://localhost:3000',
},
})

View File

@@ -1,8 +0,0 @@
{
"baseUrl": "http://localhost:3000",
"experimentalStudio": true,
"env": {
"FIREBASE_USERNAME": "cypress@imex.test",
"FIREBASE_PASSWORD": "cypress"
}
}

View File

@@ -8872,13 +8872,13 @@
│ ├─ email: luis@luisrudge.net │ ├─ email: luis@luisrudge.net
│ ├─ path: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-flexbugs-fixes │ ├─ path: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-flexbugs-fixes
│ └─ licenseFile: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-flexbugs-fixes/LICENSE │ └─ licenseFile: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-flexbugs-fixes/LICENSE
├─ postcss-focus-visible@4.0.0 ├─ postcss-focus-open@4.0.0
│ ├─ licenses: CC0-1.0 │ ├─ licenses: CC0-1.0
│ ├─ repository: https://github.com/jonathantneal/postcss-focus-visible │ ├─ repository: https://github.com/jonathantneal/postcss-focus-open
│ ├─ publisher: Jonathan Neal │ ├─ publisher: Jonathan Neal
│ ├─ email: jonathantneal@hotmail.com │ ├─ email: jonathantneal@hotmail.com
│ ├─ path: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-focus-visible │ ├─ path: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-focus-open
│ └─ licenseFile: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-focus-visible/LICENSE.md │ └─ licenseFile: /Users/pfic/Documents/Development/bodyshop/client/node_modules/postcss-focus-open/LICENSE.md
├─ postcss-focus-within@3.0.0 ├─ postcss-focus-within@3.0.0
│ ├─ licenses: CC0-1.0 │ ├─ licenses: CC0-1.0
│ ├─ repository: https://github.com/jonathantneal/postcss-focus-within │ ├─ repository: https://github.com/jonathantneal/postcss-focus-within

15967
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,85 +4,83 @@
"private": true, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.9", "@ant-design/compatible": "^5.1.2",
"@ant-design/pro-layout": "^7.17.16",
"@apollo/client": "^3.8.10",
"@asseinfo/react-kanban": "^2.2.0", "@asseinfo/react-kanban": "^2.2.0",
"@craco/craco": "^7.0.0", "@craco/craco": "^7.1.0",
"@fingerprintjs/fingerprintjs": "^3.4.2", "@fingerprintjs/fingerprintjs": "^4.2.1",
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@sentry/react": "^7.40.0", "@reduxjs/toolkit": "^2.0.1",
"@sentry/tracing": "^7.40.0", "@sentry/react": "^7.93.0",
"@splitsoftware/splitio-react": "^1.8.1", "@sentry/tracing": "^7.93.0",
"@tanem/react-nprogress": "^5.0.8", "@splitsoftware/splitio-react": "^1.11.0",
"antd": "^4.24.8", "@tanem/react-nprogress": "^5.0.51",
"antd": "^5.12.8",
"apollo-link-logger": "^2.0.1", "apollo-link-logger": "^2.0.1",
"axios": "^1.3.4", "axios": "^1.6.5",
"craco-less": "^2.0.0", "craco-less": "^3.0.1",
"dayjs": "^1.11.10",
"dayjs-business-days2": "^1.2.2",
"dinero.js": "^1.9.1", "dinero.js": "^1.9.1",
"dotenv": "^16.0.1", "dotenv": "^16.3.1",
"enquire-js": "^0.2.1", "enquire-js": "^0.2.1",
"env-cmd": "^10.1.0", "env-cmd": "^10.1.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"firebase": "^9.17.1", "firebase": "^10.7.2",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"i18next": "^22.4.10", "i18next": "^23.7.16",
"i18next-browser-languagedetector": "^7.0.1", "i18next-browser-languagedetector": "^7.0.2",
"jsoneditor": "^9.9.0", "jsoneditor": "^10.0.0",
"jsreport-browser-client-dist": "^1.3.0", "jsreport-browser-client-dist": "^1.3.0",
"libphonenumber-js": "^1.10.21", "libphonenumber-js": "^1.10.53",
"logrocket": "^3.0.1", "logrocket": "^7.0.0",
"markerjs2": "^2.28.1", "markerjs2": "^2.31.4",
"moment-business-days": "^1.2.0",
"moment-timezone": "^0.5.41",
"normalize-url": "^8.0.0", "normalize-url": "^8.0.0",
"phone": "^3.1.35", "phone": "^3.1.42",
"preval.macro": "^5.0.0", "preval.macro": "^5.0.0",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^7.1.3", "query-string": "^8.1.0",
"rc-queue-anim": "^2.0.0", "rc-queue-anim": "^2.0.0",
"rc-scroll-anim": "^2.7.6", "rc-scroll-anim": "^2.7.6",
"react": "^17.0.2", "react": "^18.2.0",
"react-big-calendar": "^1.6.8", "react-big-calendar": "^1.8.6",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^4.1.1", "react-cookie": "^7.0.1",
"react-dom": "^17.0.2", "react-dom": "^18.2.0",
"react-drag-listview": "^0.2.1", "react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.0", "react-grid-gallery": "^1.0.0",
"react-grid-layout": "^1.3.4", "react-grid-layout": "1.3.4",
"react-i18next": "^12.2.0", "react-i18next": "^14.0.0",
"react-icons": "^4.7.1", "react-icons": "^5.0.1",
"react-image-lightbox": "^5.1.4", "react-image-lightbox": "^5.1.4",
"react-intersection-observer": "^9.4.3", "react-intersection-observer": "^9.5.3",
"react-number-format": "^5.1.3", "react-number-format": "^5.1.4",
"react-redux": "^8.0.5", "react-redux": "^9.1.0",
"react-resizable": "^3.0.4", "react-resizable": "^3.0.5",
"react-router-dom": "^5.3.0", "react-router-dom": "^6.21.3",
"react-scripts": "^5.0.1", "react-scripts": "^5.0.1",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-sublime-video": "^0.2.5", "react-sublime-video": "^0.2.5",
"react-virtualized": "^9.22.3", "react-virtualized": "^9.22.5",
"recharts": "^2.4.3", "recharts": "^2.10.4",
"redux": "^4.2.1", "redux": "^5.0.1",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"redux-saga": "^1.2.2", "redux-saga": "^1.3.0",
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^4.1.7", "reselect": "^5.1.0",
"sass": "^1.58.3", "sass": "^1.70.0",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.7.4",
"styled-components": "^5.3.6", "styled-components": "^6.1.8",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",
"web-vitals": "^2.1.4", "terser-webpack-plugin": "^5.3.10",
"workbox-background-sync": "^6.5.3", "web-vitals": "^3.5.1",
"workbox-broadcast-update": "^6.5.3", "workbox-core": "^7.0.0",
"workbox-cacheable-response": "^6.5.3", "workbox-expiration": "^7.0.0",
"workbox-core": "^6.5.3", "workbox-navigation-preload": "^7.0.0",
"workbox-expiration": "^6.5.3", "workbox-precaching": "^7.0.0",
"workbox-google-analytics": "^6.5.3", "workbox-routing": "^7.0.0",
"workbox-navigation-preload": "^6.5.3", "workbox-strategies": "^7.0.0",
"workbox-precaching": "^6.5.3",
"workbox-range-requests": "^6.5.3",
"workbox-routing": "^6.5.3",
"workbox-strategies": "^6.5.3",
"workbox-streams": "^6.5.3",
"yauzl": "^2.10.0" "yauzl": "^2.10.0"
}, },
"scripts": { "scripts": {
@@ -119,12 +117,13 @@
"react-error-overlay": "6.0.9" "react-error-overlay": "6.0.9"
}, },
"devDependencies": { "devDependencies": {
"@sentry/webpack-plugin": "^1.20.0", "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/cypress": "^8.0.3", "@sentry/webpack-plugin": "^2.10.2",
"cypress": "^10.3.1", "@testing-library/cypress": "^10.0.1",
"eslint-plugin-cypress": "^2.12.1", "cypress": "^13.6.3",
"eslint-plugin-cypress": "^2.15.1",
"react-error-overlay": "6.0.11", "react-error-overlay": "6.0.11",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"source-map-explorer": "^2.5.2" "source-map-explorer": "^2.5.3"
} }
} }

View File

@@ -190,7 +190,7 @@ This package contains the following license and notice below:
# @firebase/logger # @firebase/logger
This package serves as the base of all logging in the JS SDK. Any logging that This package serves as the base of all logging in the JS SDK. Any logging that
is intended to be visible to Firebase end developers should go through this is intended to be open to Firebase end developers should go through this
module. module.
## Basic Usage ## Basic Usage
@@ -9375,7 +9375,7 @@ parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying. a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible to the extent that it includes a convenient and prominently open
feature that (1) displays an appropriate copyright notice, and (2) feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the extent that warranties are provided), that licensees may convey the

View File

@@ -1029,7 +1029,7 @@ The following NPM packages may be included in this product:
- postcss-dir-pseudo-class@5.0.0 - postcss-dir-pseudo-class@5.0.0
- postcss-double-position-gradients@1.0.0 - postcss-double-position-gradients@1.0.0
- postcss-env-function@2.0.2 - postcss-env-function@2.0.2
- postcss-focus-visible@4.0.0 - postcss-focus-open@4.0.0
- postcss-focus-within@3.0.0 - postcss-focus-within@3.0.0
- postcss-gap-properties@2.0.0 - postcss-gap-properties@2.0.0
- postcss-image-set-function@3.0.1 - postcss-image-set-function@3.0.1
@@ -1699,7 +1699,7 @@ This package contains the following license and notice below:
# @firebase/logger # @firebase/logger
This package serves as the base of all logging in the JS SDK. Any logging that This package serves as the base of all logging in the JS SDK. Any logging that
is intended to be visible to Firebase end developers should go through this is intended to be open to Firebase end developers should go through this
module. module.
## Basic Usage ## Basic Usage
@@ -24029,7 +24029,7 @@ parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying. a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible to the extent that it includes a convenient and prominently open
feature that (1) displays an appropriate copyright notice, and (2) feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the extent that warranties are provided), that licensees may convey the

View File

@@ -2,14 +2,15 @@ import { ApolloProvider } from "@apollo/client";
import { SplitFactory, SplitSdk } from "@splitsoftware/splitio-react"; import { SplitFactory, SplitSdk } from "@splitsoftware/splitio-react";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import enLocale from "antd/es/locale/en_US"; import enLocale from "antd/es/locale/en_US";
import moment from "moment"; import dayjs from "../utils/day";
import 'dayjs/locale/en';
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import client from "../utils/GraphQLClient"; import client from "../utils/GraphQLClient";
import App from "./App"; import App from "./App";
moment.locale("en-US"); dayjs.locale("en");
export const factory = SplitSdk({ export const factory = SplitSdk({
core: { core: {

View File

@@ -1,11 +1,11 @@
import { useClient } from "@splitsoftware/splitio-react"; import {useSplitClient} from "@splitsoftware/splitio-react";
import { Button, Result } from "antd"; import {Button, Result} from "antd";
import LogRocket from "logrocket"; import LogRocket from "logrocket";
import React, { lazy, Suspense, useEffect } from "react"; import React, {lazy, Suspense, useEffect, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { Route, Switch } from "react-router-dom"; import {Route, Routes} from "react-router-dom";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import DocumentEditorContainer from "../components/document-editor/document-editor.container"; import DocumentEditorContainer from "../components/document-editor/document-editor.container";
import ErrorBoundary from "../components/error-boundary/error-boundary.component"; import ErrorBoundary from "../components/error-boundary/error-boundary.component";
//Component Imports //Component Imports
@@ -13,149 +13,136 @@ import LoadingSpinner from "../components/loading-spinner/loading-spinner.compon
import DisclaimerPage from "../pages/disclaimer/disclaimer.page"; import DisclaimerPage from "../pages/disclaimer/disclaimer.page";
import LandingPage from "../pages/landing/landing.page"; import LandingPage from "../pages/landing/landing.page";
import TechPageContainer from "../pages/tech/tech.page.container"; import TechPageContainer from "../pages/tech/tech.page.container";
import { setOnline } from "../redux/application/application.actions"; import {setOnline} from "../redux/application/application.actions";
import { selectOnline } from "../redux/application/application.selectors"; import {selectOnline} from "../redux/application/application.selectors";
import { checkUserSession } from "../redux/user/user.actions"; import {checkUserSession} from "../redux/user/user.actions";
import { import {selectBodyshop, selectCurrentUser,} from "../redux/user/user.selectors";
selectBodyshop, import PrivateRoute from "../components/PrivateRoute";
selectCurrentUser,
} from "../redux/user/user.selectors";
import PrivateRoute from "../utils/private-route";
import "./App.styles.scss"; import "./App.styles.scss";
import handleBeta from "../utils/betaHandler";
const ResetPassword = lazy(() => const ResetPassword = lazy(() =>
import("../pages/reset-password/reset-password.component") import("../pages/reset-password/reset-password.component")
); );
const ManagePage = lazy(() => import("../pages/manage/manage.page.container")); const ManagePage = lazy(() => import("../pages/manage/manage.page.container"));
const SignInPage = lazy(() => import("../pages/sign-in/sign-in.page")); const SignInPage = lazy(() => import("../pages/sign-in/sign-in.page"));
const CsiPage = lazy(() => import("../pages/csi/csi.container.page")); const CsiPage = lazy(() => import("../pages/csi/csi.container.page"));
const MobilePaymentContainer = lazy(() => const MobilePaymentContainer = lazy(() =>
import("../pages/mobile-payment/mobile-payment.container") import("../pages/mobile-payment/mobile-payment.container")
); );
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
online: selectOnline, online: selectOnline,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
checkUserSession: () => dispatch(checkUserSession()), checkUserSession: () => dispatch(checkUserSession()),
setOnline: (isOnline) => dispatch(setOnline(isOnline)), setOnline: (isOnline) => dispatch(setOnline(isOnline)),
}); });
export function App({ export function App({bodyshop, checkUserSession, currentUser, online, setOnline}) {
bodyshop,
checkUserSession,
currentUser,
online,
setOnline,
}) {
const client = useClient();
useEffect(() => { const client = useSplitClient().client;
if (!navigator.onLine) { const [listenersAdded, setListenersAdded] = useState(false)
setOnline(false); const {t} = useTranslation();
}
checkUserSession();
}, [checkUserSession, setOnline]);
//const b = Grid.useBreakpoint(); useEffect(() => {
// console.log("Breakpoints:", b); if (!navigator.onLine) {
setOnline(false);
const { t } = useTranslation();
window.addEventListener("offline", function (e) {
setOnline(false);
});
window.addEventListener("online", function (e) {
setOnline(true);
});
useEffect(() => {
if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid);
LogRocket.init("rome-online/rome-online");
if (client.getTreatment("LogRocket_Tracking") === "on") {
LogRocket.init("rome-online/rome-online");
}
}
}, [bodyshop, client, currentUser.authorized]);
if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}
if (!online)
return (
<Result
status="warning"
title={t("general.labels.nointernet")}
subTitle={t("general.labels.nointernet_sub")}
extra={
<Button
type="primary"
onClick={() => {
window.location.reload();
}}
>
{t("general.actions.refresh")}
</Button>
} }
/>
);
return ( checkUserSession();
<Switch> }, [checkUserSession, setOnline]);
<Suspense fallback={<LoadingSpinner />}>
<ErrorBoundary> //const b = Grid.useBreakpoint();
<Route exact path="/" component={LandingPage} /> // console.log("Breakpoints:", b);
</ErrorBoundary>
<ErrorBoundary> // Associate event listeners, memoize to prevent multiple listeners being added
<Route exact path="/signin" component={SignInPage} /> useEffect(() => {
</ErrorBoundary> const offlineListener = (e) => {
<ErrorBoundary> setOnline(false);
<Route exact path="/resetpassword" component={ResetPassword} /> }
</ErrorBoundary>
<ErrorBoundary> const onlineListener = (e) => {
<Route exact path="/csi/:surveyId" component={CsiPage} /> setOnline(true);
</ErrorBoundary> }
<ErrorBoundary>
<Route exact path="/disclaimer" component={DisclaimerPage} /> if (!listenersAdded) {
</ErrorBoundary> console.log('Added events for offline and online');
<ErrorBoundary> window.addEventListener("offline", offlineListener);
<Route window.addEventListener("online", onlineListener);
exact setListenersAdded(true);
path="/mp/:paymentIs" }
component={MobilePaymentContainer}
/> return () => {
</ErrorBoundary> window.removeEventListener("offline", offlineListener);
<ErrorBoundary> window.removeEventListener("online", onlineListener);
<PrivateRoute }
isAuthorized={currentUser.authorized} }, [setOnline, listenersAdded]);
path="/manage"
component={ManagePage} useEffect(() => {
/> if (currentUser.authorized && bodyshop) {
</ErrorBoundary> client.setAttribute("imexshopid", bodyshop.imexshopid);
<ErrorBoundary>
<PrivateRoute if (
isAuthorized={currentUser.authorized} client.getTreatment("LogRocket_Tracking") === "on" ||
path="/tech" window.location.hostname === 'beta.romeonline.io'
component={TechPageContainer} ) {
/> console.log("LR Start");
</ErrorBoundary> LogRocket.init("rome-online/rome-online");
<ErrorBoundary> }
<PrivateRoute }
isAuthorized={currentUser.authorized} }, [bodyshop, client, currentUser.authorized]);
path="/edit"
component={DocumentEditorContainer} if (currentUser.authorized === null) {
/> return <LoadingSpinner message={t("general.labels.loggingin")}/>;
</ErrorBoundary> }
</Suspense>
</Switch> handleBeta();
);
if (!online)
return (
<Result
status="warning"
title={t("general.labels.nointernet")}
subTitle={t("general.labels.nointernet_sub")}
extra={
<Button
type="primary"
onClick={() => {
window.location.reload();
}}
>
{t("general.actions.refresh")}
</Button>
}
/>
);
// Any route that is not assigned and matched will default to the Landing Page component
return (
<Suspense fallback={<LoadingSpinner message="Rome Online"/>}>
<Routes>
<Route path="*" element={<ErrorBoundary><LandingPage/></ErrorBoundary>}/>
<Route path="/signin" element={<ErrorBoundary><SignInPage/></ErrorBoundary>}/>
<Route path="/resetpassword" element={<ErrorBoundary><ResetPassword/></ErrorBoundary>}/>
<Route path="/csi/:surveyId" element={<ErrorBoundary><CsiPage/></ErrorBoundary>}/>
<Route path="/disclaimer" element={<ErrorBoundary><DisclaimerPage/></ErrorBoundary>}/>
<Route path="/mp/:paymentIs" element={<ErrorBoundary><MobilePaymentContainer/></ErrorBoundary>}/>
<Route path="/manage/*" element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="*" element={<ManagePage/>}/>
</Route>
<Route path="/tech/*" element={<ErrorBoundary><PrivateRoute isAuthorized={currentUser.authorized}/></ErrorBoundary>}>
<Route path="*" element={<TechPageContainer/>}/>
</Route>
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized}/>}>
<Route path="*" element={<DocumentEditorContainer/>}/>
</Route>
</Routes>
</Suspense>
);
} }
export default connect(mapStateToProps, mapDispatchToProps)(App); export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@@ -1,6 +1,14 @@
//Global Styles. //Global Styles.
@import "react-big-calendar/lib/sass/styles"; @import "react-big-calendar/lib/sass/styles";
.ant-menu-item-divider {
border-bottom: 1px solid #74695c !important;
}
.ant-menu-dark .ant-menu-item:hover {
background-color: #1890ff !important;
}
.imex-table-header { .imex-table-header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -0,0 +1,17 @@
import React, {useEffect} from "react";
import {Outlet, useLocation, useNavigate} from "react-router-dom";
function PrivateRoute({component: Component, isAuthorized, ...rest}) {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (!isAuthorized) {
navigate(`/signin?redirect=${location.pathname}`);
}
}, [isAuthorized, navigate, location]);
return <Outlet/>;
}
export default PrivateRoute;

View File

@@ -1,31 +1,31 @@
import { Button } from "antd"; import {Button} from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { setModalContext } from "../../redux/modals/modals.actions"; import {setModalContext} from "../../redux/modals/modals.actions";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setRefundPaymentContext: (context) => setRefundPaymentContext: (context) =>
dispatch(setModalContext({ context: context, modal: "refund_payment" })), dispatch(setModalContext({context: context, modal: "refund_payment"})),
}); });
function Test({ setRefundPaymentContext, refundPaymentModal }) { function Test({setRefundPaymentContext, refundPaymentModal}) {
console.log("refundPaymentModal", refundPaymentModal); console.log("refundPaymentModal", refundPaymentModal);
return ( return (
<div> <div>
<Button <Button
onClick={() => onClick={() =>
setRefundPaymentContext({ setRefundPaymentContext({
context: {}, context: {},
}) })
} }
> >
Open Modal Open Modal
</Button> </Button>
</div> </div>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(Test); export default connect(mapStateToProps, mapDispatchToProps)(Test);

View File

@@ -1,233 +1,233 @@
import { Input, Table, Checkbox, Card, Space } from "antd"; import {Card, Checkbox, Input, Space, Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort, dateSort } from "../../utils/sorters"; import {alphaSort, dateSort} from "../../utils/sorters";
import PayableExportButton from "../payable-export-button/payable-export-button.component"; import PayableExportButton from "../payable-export-button/payable-export-button.component";
import PayableExportAll from "../payable-export-all-button/payable-export-all-button.component"; import PayableExportAll from "../payable-export-all-button/payable-export-all-button.component";
import { DateFormatter } from "../../utils/DateFormatter"; import {DateFormatter} from "../../utils/DateFormatter";
import queryString from "query-string"; import queryString from "query-string";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component"; import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component"; import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component"; import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-mark-selected-exported.component";
import {pageLimit} from "../../utils/config"; import {pageLimit} from "../../utils/config";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(AccountingPayablesTableComponent); )(AccountingPayablesTableComponent);
export function AccountingPayablesTableComponent({ export function AccountingPayablesTableComponent({
bodyshop, bodyshop,
loading, loading,
bills, bills,
refetch, refetch,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [selectedBills, setSelectedBills] = useState([]); const [selectedBills, setSelectedBills] = useState([]);
const [transInProgress, setTransInProgress] = useState(false); const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
search: "", search: "",
}); });
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({...state, filteredInfo: filters, sortedInfo: sorter});
}; };
const columns = [ const columns = [
{ {
title: t("bills.fields.vendorname"), title: t("bills.fields.vendorname"),
dataIndex: "vendorname", dataIndex: "vendorname",
key: "vendorname", key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name), sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order, state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<Link <Link
to={{ to={{
pathname: `/manage/shop/vendors`, pathname: `/manage/shop/vendors`,
search: queryString.stringify({ selectedvendor: record.vendor.id }), search: queryString.stringify({selectedvendor: record.vendor.id}),
}} }}
>
{record.vendor.name}
</Link>
),
},
{
title: t("bills.fields.invoice_number"),
dataIndex: "invoice_number",
key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder:
state.sortedInfo.columnKey === "invoice_number" &&
state.sortedInfo.order,
render: (text, record) => (
<Link
to={{
pathname: `/manage/bills`,
search: queryString.stringify({
billid: record.id,
vendorid: record.vendor.id,
}),
}}
>
{record.invoice_number}
</Link>
),
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={`/manage/jobs/${record.job.id}`}>{record.job.ro_number}</Link>
),
},
{
title: t("bills.fields.date"),
dataIndex: "date",
key: "date",
sorter: (a, b) => dateSort(a.date, b.date),
sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
},
{
title: t("bills.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.total}</CurrencyFormatter>
),
},
{
title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => (
<Checkbox disabled checked={record.is_credit_memo}/>
),
},
{
title: t("exportlogs.labels.attempts"),
dataIndex: "attempts",
key: "attempts",
render: (text, record) => (
<ExportLogsCountDisplay logs={record.exportlogs}/>
),
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => (
<PayableExportButton
billId={record.id}
disabled={transInProgress || !!record.exported}
loadingCallback={setTransInProgress}
setSelectedBills={setSelectedBills}
refetch={refetch}
/>
),
},
];
const handleSearch = (e) => {
setState({...state, search: e.target.value});
logImEXEvent("accounting_payables_table_search");
};
const dataSource = state.search
? bills.filter(
(v) =>
(v.vendor.name || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.invoice_number || "")
.toLowerCase()
.includes(state.search.toLowerCase())
)
: bills;
return (
<Card
extra={
<Space wrap>
<BillMarkSelectedExported
billids={selectedBills}
disabled={transInProgress || selectedBills.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedBills}
refetch={refetch}
/>
<PayableExportAll
billids={selectedBills}
disabled={transInProgress || selectedBills.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedBills}
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && (
<QboAuthorizeComponent/>
)}
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</Space>
}
> >
{record.vendor.name} <Table
</Link> loading={loading}
), dataSource={dataSource}
}, pagination={{position: "top", pageSize: pageLimit}}
{ columns={columns}
title: t("bills.fields.invoice_number"), rowKey="id"
dataIndex: "invoice_number", onChange={handleTableChange}
key: "invoice_number", rowSelection={{
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number), onSelectAll: (selected, selectedRows) =>
sortOrder: setSelectedBills(selectedRows.map((i) => i.id)),
state.sortedInfo.columnKey === "invoice_number" && onSelect: (record, selected, selectedRows, nativeEvent) => {
state.sortedInfo.order, setSelectedBills(selectedRows.map((i) => i.id));
render: (text, record) => ( },
<Link getCheckboxProps: (record) => ({
to={{ disabled: record.exported,
pathname: `/manage/bills`, }),
search: queryString.stringify({ selectedRowKeys: selectedBills,
billid: record.id, type: "checkbox",
vendorid: record.vendor.id, }}
}), />
}} </Card>
> );
{record.invoice_number}
</Link>
),
},
{
title: t("jobs.fields.ro_number"),
dataIndex: "ro_number",
key: "ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => (
<Link to={`/manage/jobs/${record.job.id}`}>{record.job.ro_number}</Link>
),
},
{
title: t("bills.fields.date"),
dataIndex: "date",
key: "date",
sorter: (a, b) => dateSort(a.date, b.date),
sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
},
{
title: t("bills.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.total}</CurrencyFormatter>
),
},
{
title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => (
<Checkbox disabled checked={record.is_credit_memo} />
),
},
{
title: t("exportlogs.labels.attempts"),
dataIndex: "attempts",
key: "attempts",
render: (text, record) => (
<ExportLogsCountDisplay logs={record.exportlogs} />
),
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => (
<PayableExportButton
billId={record.id}
disabled={transInProgress || !!record.exported}
loadingCallback={setTransInProgress}
setSelectedBills={setSelectedBills}
refetch={refetch}
/>
),
},
];
const handleSearch = (e) => {
setState({ ...state, search: e.target.value });
logImEXEvent("accounting_payables_table_search");
};
const dataSource = state.search
? bills.filter(
(v) =>
(v.vendor.name || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.invoice_number || "")
.toLowerCase()
.includes(state.search.toLowerCase())
)
: bills;
return (
<Card
extra={
<Space wrap>
<BillMarkSelectedExported
billids={selectedBills}
disabled={transInProgress || selectedBills.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedBills}
refetch={refetch}
/>
<PayableExportAll
billids={selectedBills}
disabled={transInProgress || selectedBills.length === 0}
loadingCallback={setTransInProgress}
completedCallback={setSelectedBills}
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && (
<QboAuthorizeComponent />
)}
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</Space>
}
>
<Table
loading={loading}
dataSource={dataSource}
pagination={{ position: "top", pageSize: pageLimit }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedBills(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedBills(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedBills,
type: "checkbox",
}}
/>
</Card>
);
} }

View File

@@ -1,14 +1,14 @@
import { Card, Input, Space, Table } from "antd"; import {Card, Input, Space, Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter, DateTimeFormatter } from "../../utils/DateFormatter"; import {DateFormatter, DateTimeFormatter} from "../../utils/DateFormatter";
import { alphaSort, dateSort } from "../../utils/sorters"; import {alphaSort, dateSort} from "../../utils/sorters";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component"; import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import PaymentExportButton from "../payment-export-button/payment-export-button.component"; import PaymentExportButton from "../payment-export-button/payment-export-button.component";
@@ -18,215 +18,215 @@ import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import {pageLimit} from "../../utils/config"; import {pageLimit} from "../../utils/config";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(AccountingPayablesTableComponent); )(AccountingPayablesTableComponent);
export function AccountingPayablesTableComponent({ export function AccountingPayablesTableComponent({
bodyshop, bodyshop,
loading, loading,
payments, payments,
refetch, refetch,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [selectedPayments, setSelectedPayments] = useState([]); const [selectedPayments, setSelectedPayments] = useState([]);
const [transInProgress, setTransInProgress] = useState(false); const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
search: "", search: "",
}); });
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({...state, filteredInfo: filters, sortedInfo: sorter});
}; };
const columns = [ const columns = [
{ {
title: t("jobs.fields.ro_number"), title: t("jobs.fields.ro_number"),
dataIndex: "ro_number", dataIndex: "ro_number",
key: "ro_number", key: "ro_number",
sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number), sorter: (a, b) => alphaSort(a.job.ro_number, b.job.ro_number),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order, state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<Link to={"/manage/jobs/" + record.job.id}>{record.job.ro_number}</Link> <Link to={"/manage/jobs/" + record.job.id}>{record.job.ro_number}</Link>
), ),
}, },
{ {
title: t("payments.fields.date"), title: t("payments.fields.date"),
dataIndex: "date", dataIndex: "date",
key: "date", key: "date",
sorter: (a, b) => dateSort(a.date, b.date), sorter: (a, b) => dateSort(a.date, b.date),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order, state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>, render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
}, },
{ {
title: t("jobs.fields.owner"), title: t("jobs.fields.owner"),
dataIndex: "owner", dataIndex: "owner",
key: "owner", key: "owner",
ellipsis: true, ellipsis: true,
sorter: (a, b) => alphaSort(a.job.ownr_ln, b.job.ownr_ln), sorter: (a, b) => alphaSort(a.job.ownr_ln, b.job.ownr_ln),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "owner" && state.sortedInfo.order, state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => { render: (text, record) => {
return record.job.owner ? ( return record.job.owner ? (
<Link to={"/manage/owners/" + record.job.owner.id}> <Link to={"/manage/owners/" + record.job.owner.id}>
<OwnerNameDisplay ownerObject={record.job} /> <OwnerNameDisplay ownerObject={record.job}/>
</Link> </Link>
) : ( ) : (
<span> <span>
<OwnerNameDisplay ownerObject={record.job} /> <OwnerNameDisplay ownerObject={record.job}/>
</span> </span>
); );
}, },
}, },
{ {
title: t("payments.fields.amount"), title: t("payments.fields.amount"),
dataIndex: "amount", dataIndex: "amount",
key: "amount", key: "amount",
render: (text, record) => ( render: (text, record) => (
<CurrencyFormatter>{record.amount}</CurrencyFormatter> <CurrencyFormatter>{record.amount}</CurrencyFormatter>
), ),
}, },
{ {
title: t("payments.fields.memo"), title: t("payments.fields.memo"),
dataIndex: "memo", dataIndex: "memo",
key: "memo", key: "memo",
}, },
{ {
title: t("payments.fields.transactionid"), title: t("payments.fields.transactionid"),
dataIndex: "transactionid", dataIndex: "transactionid",
key: "transactionid", key: "transactionid",
}, },
{ {
title: t("payments.fields.created_at"), title: t("payments.fields.created_at"),
dataIndex: "created_at", dataIndex: "created_at",
key: "created_at", key: "created_at",
render: (text, record) => ( render: (text, record) => (
<DateTimeFormatter>{record.created_at}</DateTimeFormatter> <DateTimeFormatter>{record.created_at}</DateTimeFormatter>
), ),
}, },
{ {
title: t("payments.fields.exportedat"), title: t("payments.fields.exportedat"),
dataIndex: "exportedat", dataIndex: "exportedat",
key: "exportedat", key: "exportedat",
render: (text, record) => ( render: (text, record) => (
<DateTimeFormatter>{record.exportedat}</DateTimeFormatter> <DateTimeFormatter>{record.exportedat}</DateTimeFormatter>
), ),
}, },
{ {
title: t("exportlogs.labels.attempts"), title: t("exportlogs.labels.attempts"),
dataIndex: "attempts", dataIndex: "attempts",
key: "attempts", key: "attempts",
render: (text, record) => ( render: (text, record) => (
<ExportLogsCountDisplay logs={record.exportlogs} /> <ExportLogsCountDisplay logs={record.exportlogs}/>
), ),
}, },
{ {
title: t("general.labels.actions"), title: t("general.labels.actions"),
dataIndex: "actions", dataIndex: "actions",
key: "actions", key: "actions",
sorter: (a, b) => a.clm_total - b.clm_total, sorter: (a, b) => a.clm_total - b.clm_total,
render: (text, record) => ( render: (text, record) => (
<PaymentExportButton <PaymentExportButton
paymentId={record.id} paymentId={record.id}
disabled={transInProgress || !!record.exportedat} disabled={transInProgress || !!record.exportedat}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
setSelectedPayments={setSelectedPayments} setSelectedPayments={setSelectedPayments}
refetch={refetch} refetch={refetch}
/> />
), ),
}, },
]; ];
const handleSearch = (e) => { const handleSearch = (e) => {
setState({ ...state, search: e.target.value }); setState({...state, search: e.target.value});
logImEXEvent("account_payments_table_search"); logImEXEvent("account_payments_table_search");
}; };
const dataSource = state.search const dataSource = state.search
? payments.filter( ? payments.filter(
(v) => (v) =>
(v.paymentnum || "") (v.paymentnum || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
((v.job && v.job.ro_number) || "") ((v.job && v.job.ro_number) || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_fn) || "") ((v.job && v.job.ownr_fn) || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_ln) || "") ((v.job && v.job.ownr_ln) || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_co_nm) || "") ((v.job && v.job.ownr_co_nm) || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) .includes(state.search.toLowerCase())
) )
: payments; : payments;
return ( return (
<Card <Card
extra={ extra={
<Space wrap> <Space wrap>
<PaymentMarkSelectedExported <PaymentMarkSelectedExported
paymentIds={selectedPayments} paymentIds={selectedPayments}
disabled={transInProgress || selectedPayments.length === 0} disabled={transInProgress || selectedPayments.length === 0}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
completedCallback={setSelectedPayments} completedCallback={setSelectedPayments}
refetch={refetch} refetch={refetch}
/> />
<PaymentsExportAllButton <PaymentsExportAllButton
paymentIds={selectedPayments} paymentIds={selectedPayments}
disabled={transInProgress || selectedPayments.length === 0} disabled={transInProgress || selectedPayments.length === 0}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
completedCallback={setSelectedPayments} completedCallback={setSelectedPayments}
refetch={refetch} refetch={refetch}
/> />
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && ( {bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && (
<QboAuthorizeComponent /> <QboAuthorizeComponent/>
)} )}
<Input <Input
value={state.search} value={state.search}
onChange={handleSearch} onChange={handleSearch}
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
allowClear allowClear
/> />
</Space> </Space>
} }
> >
<Table <Table
loading={loading} loading={loading}
dataSource={dataSource} dataSource={dataSource}
pagination={{ position: "top", pageSize: pageLimit }} pagination={{position: "top", pageSize: pageLimit}}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
onChange={handleTableChange} onChange={handleTableChange}
rowSelection={{ rowSelection={{
onSelectAll: (selected, selectedRows) => onSelectAll: (selected, selectedRows) =>
setSelectedPayments(selectedRows.map((i) => i.id)), setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => { onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedPayments(selectedRows.map((i) => i.id)); setSelectedPayments(selectedRows.map((i) => i.id));
}, },
getCheckboxProps: (record) => ({ getCheckboxProps: (record) => ({
disabled: record.exported, disabled: record.exported,
}), }),
selectedRowKeys: selectedPayments, selectedRowKeys: selectedPayments,
type: "checkbox", type: "checkbox",
}} }}
/> />
</Card> </Card>
); );
} }

View File

@@ -1,247 +1,247 @@
import { Button, Card, Input, Space, Table } from "antd"; import {Button, Card, Input, Space, Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort, dateSort } from "../../utils/sorters"; import {alphaSort, dateSort} from "../../utils/sorters";
import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component"; import JobExportButton from "../jobs-close-export-button/jobs-close-export-button.component";
import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component"; import JobsExportAllButton from "../jobs-export-all-button/jobs-export-all-button.component";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component"; import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component";
import { DateFormatter } from "../../utils/DateFormatter"; import {DateFormatter} from "../../utils/DateFormatter";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component"; import ExportLogsCountDisplay from "../export-logs-count-display/export-logs-count-display.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(AccountingReceivablesTableComponent); )(AccountingReceivablesTableComponent);
export function AccountingReceivablesTableComponent({ export function AccountingReceivablesTableComponent({
bodyshop, bodyshop,
loading, loading,
jobs, jobs,
refetch, refetch,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [selectedJobs, setSelectedJobs] = useState([]); const [selectedJobs, setSelectedJobs] = useState([]);
const [transInProgress, setTransInProgress] = useState(false); const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
search: "", search: "",
}); });
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({...state, filteredInfo: filters, sortedInfo: sorter});
}; };
const columns = [ const columns = [
{ {
title: t("jobs.fields.ro_number"), title: t("jobs.fields.ro_number"),
dataIndex: "ro_number", dataIndex: "ro_number",
key: "ro_number", key: "ro_number",
sorter: (a, b) => alphaSort(a.ro_number, b.ro_number), sorter: (a, b) => alphaSort(a.ro_number, b.ro_number),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order, state.sortedInfo.columnKey === "ro_number" && state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<Link to={"/manage/jobs/" + record.id}>{record.ro_number}</Link> <Link to={"/manage/jobs/" + record.id}>{record.ro_number}</Link>
), ),
}, },
{ {
title: t("jobs.fields.status"), title: t("jobs.fields.status"),
dataIndex: "status", dataIndex: "status",
key: "status", key: "status",
sorter: (a, b) => a.status - b.status, sorter: (a, b) => a.status - b.status,
sortOrder: sortOrder:
state.sortedInfo.columnKey === "status" && state.sortedInfo.order, state.sortedInfo.columnKey === "status" && state.sortedInfo.order,
}, },
{ {
title: t("jobs.fields.date_invoiced"), title: t("jobs.fields.date_invoiced"),
dataIndex: "date_invoiced", dataIndex: "date_invoiced",
key: "date_invoiced", key: "date_invoiced",
sorter: (a, b) => dateSort(a.date_invoiced, b.date_invoiced), sorter: (a, b) => dateSort(a.date_invoiced, b.date_invoiced),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "date_invoiced" && state.sortedInfo.columnKey === "date_invoiced" &&
state.sortedInfo.order, state.sortedInfo.order,
render: (text, record) => ( render: (text, record) => (
<DateFormatter>{record.date_invoiced}</DateFormatter> <DateFormatter>{record.date_invoiced}</DateFormatter>
), ),
}, },
{ {
title: t("jobs.fields.owner"), title: t("jobs.fields.owner"),
dataIndex: "owner", dataIndex: "owner",
key: "owner", key: "owner",
sorter: (a, b) => alphaSort(a.ownr_ln, b.ownr_ln), sorter: (a, b) => alphaSort(a.ownr_ln, b.ownr_ln),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "owner" && state.sortedInfo.order, state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
render: (text, record) => { render: (text, record) => {
return record.owner ? ( return record.owner ? (
<Link to={"/manage/owners/" + record.owner.id}> <Link to={"/manage/owners/" + record.owner.id}>
<OwnerNameDisplay ownerObject={record} /> <OwnerNameDisplay ownerObject={record}/>
</Link> </Link>
) : ( ) : (
<span> <span>
<OwnerNameDisplay ownerObject={record} /> <OwnerNameDisplay ownerObject={record}/>
</span> </span>
); );
}, },
}, },
{ {
title: t("jobs.fields.vehicle"), title: t("jobs.fields.vehicle"),
dataIndex: "vehicle", dataIndex: "vehicle",
key: "vehicle", key: "vehicle",
ellipsis: true, ellipsis: true,
render: (text, record) => { render: (text, record) => {
return record.vehicleid ? ( return record.vehicleid ? (
<Link to={"/manage/vehicles/" + record.vehicleid}> <Link to={"/manage/vehicles/" + record.vehicleid}>
{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${ {`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || "" record.v_model_desc || ""
}`} }`}
</Link> </Link>
) : ( ) : (
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${ <span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${
record.v_model_desc || "" record.v_model_desc || ""
}`}</span> }`}</span>
); );
}, },
}, },
{ {
title: t("jobs.fields.clm_no"), title: t("jobs.fields.clm_no"),
dataIndex: "clm_no", dataIndex: "clm_no",
key: "clm_no", key: "clm_no",
ellipsis: true, ellipsis: true,
sorter: (a, b) => alphaSort(a.clm_no, b.clm_no), sorter: (a, b) => alphaSort(a.clm_no, b.clm_no),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "clm_no" && state.sortedInfo.order, state.sortedInfo.columnKey === "clm_no" && state.sortedInfo.order,
}, },
{ {
title: t("jobs.fields.clm_total"), title: t("jobs.fields.clm_total"),
dataIndex: "clm_total", dataIndex: "clm_total",
key: "clm_total", key: "clm_total",
sorter: (a, b) => a.clm_total - b.clm_total, sorter: (a, b) => a.clm_total - b.clm_total,
sortOrder: sortOrder:
state.sortedInfo.columnKey === "clm_total" && state.sortedInfo.order, state.sortedInfo.columnKey === "clm_total" && state.sortedInfo.order,
render: (text, record) => { render: (text, record) => {
return <CurrencyFormatter>{record.clm_total}</CurrencyFormatter>; return <CurrencyFormatter>{record.clm_total}</CurrencyFormatter>;
}, },
}, },
{ {
title: t("exportlogs.labels.attempts"), title: t("exportlogs.labels.attempts"),
dataIndex: "attempts", dataIndex: "attempts",
key: "attempts", key: "attempts",
render: (text, record) => ( render: (text, record) => (
<ExportLogsCountDisplay logs={record.exportlogs} /> <ExportLogsCountDisplay logs={record.exportlogs}/>
), ),
}, },
{ {
title: t("general.labels.actions"), title: t("general.labels.actions"),
dataIndex: "actions", dataIndex: "actions",
key: "actions", key: "actions",
render: (text, record) => ( render: (text, record) => (
<Space wrap> <Space wrap>
<JobExportButton <JobExportButton
jobId={record.id} jobId={record.id}
disabled={!!record.date_exported} disabled={!!record.date_exported}
setSelectedJobs={setSelectedJobs} setSelectedJobs={setSelectedJobs}
refetch={refetch} refetch={refetch}
/> />
<Link to={`/manage/jobs/${record.id}/close`}> <Link to={`/manage/jobs/${record.id}/close`}>
<Button>{t("jobs.labels.viewallocations")}</Button> <Button>{t("jobs.labels.viewallocations")}</Button>
</Link> </Link>
</Space> </Space>
), ),
}, },
]; ];
const handleSearch = (e) => { const handleSearch = (e) => {
setState({ ...state, search: e.target.value }); setState({...state, search: e.target.value});
logImEXEvent("accounting_receivables_search"); logImEXEvent("accounting_receivables_search");
}; };
const dataSource = state.search const dataSource = state.search
? jobs.filter( ? jobs.filter(
(v) => (v) =>
(v.ro_number || "") (v.ro_number || "")
.toString() .toString()
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.ownr_fn || "") (v.ownr_fn || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.ownr_ln || "") (v.ownr_ln || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.ownr_co_nm || "") (v.ownr_co_nm || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.v_model_desc || "") (v.v_model_desc || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.v_make_desc || "") (v.v_make_desc || "")
.toLowerCase() .toLowerCase()
.includes(state.search.toLowerCase()) || .includes(state.search.toLowerCase()) ||
(v.clm_no || "").toLowerCase().includes(state.search.toLowerCase()) (v.clm_no || "").toLowerCase().includes(state.search.toLowerCase())
) )
: jobs; : jobs;
return ( return (
<Card <Card
extra={ extra={
<Space wrap> <Space wrap>
{!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && ( {!bodyshop.cdk_dealerid && !bodyshop.pbs_serialnumber && (
<JobsExportAllButton <JobsExportAllButton
jobIds={selectedJobs} jobIds={selectedJobs}
disabled={transInProgress || selectedJobs.length === 0} disabled={transInProgress || selectedJobs.length === 0}
loadingCallback={setTransInProgress} loadingCallback={setTransInProgress}
completedCallback={setSelectedJobs} completedCallback={setSelectedJobs}
refetch={refetch} refetch={refetch}
/>
)}
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && (
<QboAuthorizeComponent/>
)}
<Input.Search
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</Space>
}
>
<Table
loading={loading}
dataSource={dataSource}
pagination={{position: "top"}}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedJobs,
type: "checkbox",
}}
/> />
)} </Card>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && ( );
<QboAuthorizeComponent />
)}
<Input.Search
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</Space>
}
>
<Table
loading={loading}
dataSource={dataSource}
pagination={{ position: "top" }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelectAll: (selected, selectedRows) =>
setSelectedJobs(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedJobs(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedJobs,
type: "checkbox",
}}
/>
</Card>
);
} }

View File

@@ -1,6 +1,6 @@
import { Alert } from "antd"; import {Alert} from "antd";
import React from "react"; import React from "react";
export default function AlertComponent(props) { export default function AlertComponent(props) {
return <Alert {...props} />; return <Alert {...props} />;
} }

View File

@@ -1,19 +1,19 @@
import { shallow } from "enzyme"; import {shallow} from "enzyme";
import React from "react"; import React from "react";
import Alert from "./alert.component"; import Alert from "./alert.component";
describe("Alert component", () => { describe("Alert component", () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
const mockProps = { const mockProps = {
type: "error", type: "error",
message: "Test error message.", message: "Test error message.",
}; };
wrapper = shallow(<Alert {...mockProps} />); wrapper = shallow(<Alert {...mockProps} />);
}); });
it("should render Alert component", () => { it("should render Alert component", () => {
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });

View File

@@ -1,72 +1,72 @@
import { Select, Button, Popover, InputNumber } from "antd"; import {Button, InputNumber, Popover, Select} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
export function AllocationsAssignmentComponent({ export function AllocationsAssignmentComponent({
bodyshop, bodyshop,
handleAssignment, handleAssignment,
assignment, assignment,
setAssignment, setAssignment,
visibilityState, visibilityState,
maxHours maxHours
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const onChange = e => { const onChange = e => {
setAssignment({ ...assignment, employeeid: e }); setAssignment({...assignment, employeeid: e});
}; };
const [visibility, setVisibility] = visibilityState; const [visibility, setVisibility] = visibilityState;
const popContent = ( const popContent = (
<div> <div>
<Select id="employeeSelector" <Select id="employeeSelector"
showSearch showSearch
style={{ width: 200 }} style={{width: 200}}
placeholder='Select a person' placeholder='Select a person'
optionFilterProp='children' optionFilterProp='children'
onChange={onChange} onChange={onChange}
filterOption={(input, option) => filterOption={(input, option) =>
option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}> }>
{bodyshop.employees.map(emp => ( {bodyshop.employees.map(emp => (
<Select.Option value={emp.id} key={emp.id}> <Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`} {`${emp.first_name} ${emp.last_name}`}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
<InputNumber <InputNumber
defaultValue={assignment.hours} defaultValue={assignment.hours}
placeholder={t("joblines.fields.mod_lb_hrs")} placeholder={t("joblines.fields.mod_lb_hrs")}
max={parseFloat(maxHours)} max={parseFloat(maxHours)}
min={0} min={0}
onChange={e => setAssignment({ ...assignment, hours: e })} onChange={e => setAssignment({...assignment, hours: e})}
/> />
<Button <Button
type='primary' type='primary'
disabled={!assignment.employeeid} disabled={!assignment.employeeid}
onClick={handleAssignment}> onClick={handleAssignment}>
Assign Assign
</Button> </Button>
<Button onClick={() => setVisibility(false)}>Close</Button> <Button onClick={() => setVisibility(false)}>Close</Button>
</div> </div>
); );
return ( return (
<Popover content={popContent} visible={visibility}> <Popover content={popContent} open={visibility}>
<Button onClick={() => setVisibility(true)}> <Button onClick={() => setVisibility(true)}>
{t("allocations.actions.assign")} {t("allocations.actions.assign")}
</Button> </Button>
</Popover> </Popover>
); );
} }
export default connect(mapStateToProps, null)(AllocationsAssignmentComponent); export default connect(mapStateToProps, null)(AllocationsAssignmentComponent);

View File

@@ -1,35 +1,35 @@
import { mount } from "enzyme"; import {mount} from "enzyme";
import React from "react"; import React from "react";
import { MockBodyshop } from "../../utils/TestingHelpers"; import {MockBodyshop} from "../../utils/TestingHelpers";
import { AllocationsAssignmentComponent } from "./allocations-assignment.component"; import {AllocationsAssignmentComponent} from "./allocations-assignment.component";
describe("AllocationsAssignmentComponent component", () => { describe("AllocationsAssignmentComponent component", () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
const mockProps = { const mockProps = {
bodyshop: MockBodyshop, bodyshop: MockBodyshop,
handleAssignment: jest.fn(), handleAssignment: jest.fn(),
assignment: {}, assignment: {},
setAssignment: jest.fn(), setAssignment: jest.fn(),
visibilityState: [false, jest.fn()], visibilityState: [false, jest.fn()],
maxHours: 4, maxHours: 4,
}; };
wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />); wrapper = mount(<AllocationsAssignmentComponent {...mockProps} />);
}); });
it("should render AllocationsAssignmentComponent component", () => { it("should render AllocationsAssignmentComponent component", () => {
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
it("should render a list of employees", () => { it("should render a list of employees", () => {
const empList = wrapper.find("#employeeSelector"); const empList = wrapper.find("#employeeSelector");
expect(empList.children()).to.have.lengthOf(2); expect(empList.children()).to.have.lengthOf(2);
}); });
it("should create an allocation on save", () => { it("should create an allocation on save", () => {
wrapper.find("Button").simulate("click"); wrapper.find("Button").simulate("click");
expect(wrapper).toMatchSnapshot(); expect(wrapper).toMatchSnapshot();
}); });
}); });

View File

@@ -1,47 +1,47 @@
import React, { useState } from "react"; import React, {useState} from "react";
import AllocationsAssignmentComponent from "./allocations-assignment.component"; import AllocationsAssignmentComponent from "./allocations-assignment.component";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries"; import {INSERT_ALLOCATION} from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { notification } from "antd"; import {notification} from "antd";
export default function AllocationsAssignmentContainer({ export default function AllocationsAssignmentContainer({
jobLineId, jobLineId,
hours, hours,
refetch, refetch,
}) { }) {
const visibilityState = useState(false); const visibilityState = useState(false);
const { t } = useTranslation(); const {t} = useTranslation();
const [assignment, setAssignment] = useState({ const [assignment, setAssignment] = useState({
joblineid: jobLineId, joblineid: jobLineId,
hours: parseFloat(hours), hours: parseFloat(hours),
employeeid: null, employeeid: null,
}); });
const [insertAllocation] = useMutation(INSERT_ALLOCATION); const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const handleAssignment = () => { const handleAssignment = () => {
insertAllocation({ variables: { alloc: { ...assignment } } }) insertAllocation({variables: {alloc: {...assignment}}})
.then((r) => { .then((r) => {
notification["success"]({ notification["success"]({
message: t("allocations.successes.save"), message: t("allocations.successes.save"),
}); });
visibilityState[1](false); visibilityState[1](false);
if (refetch) refetch(); if (refetch) refetch();
}) })
.catch((error) => { .catch((error) => {
notification["error"]({ notification["error"]({
message: t("employees.errors.saving", { message: error.message }), message: t("employees.errors.saving", {message: error.message}),
}); });
}); });
}; };
return ( return (
<AllocationsAssignmentComponent <AllocationsAssignmentComponent
handleAssignment={handleAssignment} handleAssignment={handleAssignment}
maxHours={hours} maxHours={hours}
assignment={assignment} assignment={assignment}
setAssignment={setAssignment} setAssignment={setAssignment}
visibilityState={visibilityState} visibilityState={visibilityState}
/> />
); );
} }

View File

@@ -1,68 +1,68 @@
import { Button, Popover, Select } from "antd"; import {Button, Popover, Select} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
null null
)(function AllocationsBulkAssignmentComponent({ )(function AllocationsBulkAssignmentComponent({
disabled, disabled,
bodyshop, bodyshop,
handleAssignment, handleAssignment,
assignment, assignment,
setAssignment, setAssignment,
visibilityState, visibilityState,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const onChange = (e) => { const onChange = (e) => {
setAssignment({ ...assignment, employeeid: e }); setAssignment({...assignment, employeeid: e});
}; };
const [visibility, setVisibility] = visibilityState; const [visibility, setVisibility] = visibilityState;
const popContent = ( const popContent = (
<div> <div>
<Select <Select
showSearch showSearch
style={{ width: 200 }} style={{width: 200}}
placeholder="Select a person" placeholder="Select a person"
optionFilterProp="children" optionFilterProp="children"
onChange={onChange} onChange={onChange}
filterOption={(input, option) => filterOption={(input, option) =>
option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
} }
> >
{bodyshop.employees.map((emp) => ( {bodyshop.employees.map((emp) => (
<Select.Option value={emp.id} key={emp.id}> <Select.Option value={emp.id} key={emp.id}>
{`${emp.first_name} ${emp.last_name}`} {`${emp.first_name} ${emp.last_name}`}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
<Button <Button
type="primary" type="primary"
disabled={!assignment.employeeid} disabled={!assignment.employeeid}
onClick={handleAssignment} onClick={handleAssignment}
> >
Assign Assign
</Button> </Button>
<Button onClick={() => setVisibility(false)}>Close</Button> <Button onClick={() => setVisibility(false)}>Close</Button>
</div> </div>
); );
return ( return (
<Popover content={popContent} visible={visibility}> <Popover content={popContent} open={visibility}>
<Button disabled={disabled} onClick={() => setVisibility(true)}> <Button disabled={disabled} onClick={() => setVisibility(true)}>
{t("allocations.actions.assign")} {t("allocations.actions.assign")}
</Button> </Button>
</Popover> </Popover>
); );
}); });

View File

@@ -1,47 +1,47 @@
import React, { useState } from "react"; import React, {useState} from "react";
import AllocationsBulkAssignment from "./allocations-bulk-assignment.component"; import AllocationsBulkAssignment from "./allocations-bulk-assignment.component";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { INSERT_ALLOCATION } from "../../graphql/allocations.queries"; import {INSERT_ALLOCATION} from "../../graphql/allocations.queries";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { notification } from "antd"; import {notification} from "antd";
export default function AllocationsBulkAssignmentContainer({ export default function AllocationsBulkAssignmentContainer({
jobLines, jobLines,
refetch, refetch,
}) { }) {
const visibilityState = useState(false); const visibilityState = useState(false);
const { t } = useTranslation(); const {t} = useTranslation();
const [assignment, setAssignment] = useState({ const [assignment, setAssignment] = useState({
employeeid: null, employeeid: null,
});
const [insertAllocation] = useMutation(INSERT_ALLOCATION);
const handleAssignment = () => {
const allocs = jobLines.reduce((acc, value) => {
acc.push({
joblineid: value.id,
hours: parseFloat(value.mod_lb_hrs) || 0,
employeeid: assignment.employeeid,
});
return acc;
}, []);
insertAllocation({ variables: { alloc: allocs } }).then((r) => {
notification["success"]({
message: t("employees.successes.save"),
});
visibilityState[1](false);
if (refetch) refetch();
}); });
}; const [insertAllocation] = useMutation(INSERT_ALLOCATION);
return ( const handleAssignment = () => {
<AllocationsBulkAssignment const allocs = jobLines.reduce((acc, value) => {
disabled={jobLines.length > 0 ? false : true} acc.push({
handleAssignment={handleAssignment} joblineid: value.id,
assignment={assignment} hours: parseFloat(value.mod_lb_hrs) || 0,
setAssignment={setAssignment} employeeid: assignment.employeeid,
visibilityState={visibilityState} });
/> return acc;
); }, []);
insertAllocation({variables: {alloc: allocs}}).then((r) => {
notification["success"]({
message: t("employees.successes.save"),
});
visibilityState[1](false);
if (refetch) refetch();
});
};
return (
<AllocationsBulkAssignment
disabled={jobLines.length > 0 ? false : true}
handleAssignment={handleAssignment}
assignment={assignment}
setAssignment={setAssignment}
visibilityState={visibilityState}
/>
);
} }

View File

@@ -1,20 +1,20 @@
import Icon from "@ant-design/icons"; import Icon from "@ant-design/icons";
import React from "react"; import React from "react";
import { MdRemoveCircleOutline } from "react-icons/md"; import {MdRemoveCircleOutline} from "react-icons/md";
export default function AllocationsLabelComponent({ allocation, handleClick }) { export default function AllocationsLabelComponent({allocation, handleClick}) {
return ( return (
<div style={{ display: "flex", alignItems: "center" }}> <div style={{display: "flex", alignItems: "center"}}>
<span> <span>
{`${allocation.employee.first_name || ""} ${ {`${allocation.employee.first_name || ""} ${
allocation.employee.last_name || "" allocation.employee.last_name || ""
} (${allocation.hours || ""})`} } (${allocation.hours || ""})`}
</span> </span>
<Icon <Icon
style={{ color: "red", padding: "0px 4px" }} style={{color: "red", padding: "0px 4px"}}
component={MdRemoveCircleOutline} component={MdRemoveCircleOutline}
onClick={handleClick} onClick={handleClick}
/> />
</div> </div>
); );
} }

View File

@@ -1,32 +1,32 @@
import React from "react"; import React from "react";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { DELETE_ALLOCATION } from "../../graphql/allocations.queries"; import {DELETE_ALLOCATION} from "../../graphql/allocations.queries";
import AllocationsLabelComponent from "./allocations-employee-label.component"; import AllocationsLabelComponent from "./allocations-employee-label.component";
import { notification } from "antd"; import {notification} from "antd";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
export default function AllocationsLabelContainer({ allocation, refetch }) { export default function AllocationsLabelContainer({allocation, refetch}) {
const [deleteAllocation] = useMutation(DELETE_ALLOCATION); const [deleteAllocation] = useMutation(DELETE_ALLOCATION);
const { t } = useTranslation(); const {t} = useTranslation();
const handleClick = (e) => { const handleClick = (e) => {
e.preventDefault(); e.preventDefault();
deleteAllocation({ variables: { id: allocation.id } }) deleteAllocation({variables: {id: allocation.id}})
.then((r) => { .then((r) => {
notification["success"]({ notification["success"]({
message: t("allocations.successes.deleted"), message: t("allocations.successes.deleted"),
}); });
if (refetch) refetch(); if (refetch) refetch();
}) })
.catch((error) => { .catch((error) => {
notification["error"]({ message: t("allocations.errors.deleting") }); notification["error"]({message: t("allocations.errors.deleting")});
}); });
}; };
return ( return (
<AllocationsLabelComponent <AllocationsLabelComponent
allocation={allocation} allocation={allocation}
handleClick={handleClick} handleClick={handleClick}
/> />
); );
} }

View File

@@ -1,85 +1,85 @@
import React, { useState } from "react"; import React, {useState} from "react";
import { Table } from "antd"; import {Table} from "antd";
import { alphaSort } from "../../utils/sorters"; import {alphaSort} from "../../utils/sorters";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import {DateTimeFormatter} from "../../utils/DateFormatter";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import AuditTrailValuesComponent from "../audit-trail-values/audit-trail-values.component"; import AuditTrailValuesComponent from "../audit-trail-values/audit-trail-values.component";
import {pageLimit} from "../../utils/config"; import {pageLimit} from "../../utils/config";
export default function AuditTrailListComponent({ loading, data }) { export default function AuditTrailListComponent({loading, data}) {
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
filteredInfo: {}, filteredInfo: {},
}); });
const { t } = useTranslation(); const {t} = useTranslation();
const columns = [ const columns = [
{ {
title: t("audit.fields.created"), title: t("audit.fields.created"),
dataIndex: " created", dataIndex: " created",
key: " created", key: " created",
width: "10%", width: "10%",
render: (text, record) => ( render: (text, record) => (
<DateTimeFormatter>{record.created}</DateTimeFormatter> <DateTimeFormatter>{record.created}</DateTimeFormatter>
), ),
sorter: (a, b) => a.created - b.created, sorter: (a, b) => a.created - b.created,
sortOrder: sortOrder:
state.sortedInfo.columnKey === "created" && state.sortedInfo.order, state.sortedInfo.columnKey === "created" && state.sortedInfo.order,
}, },
{ {
title: t("audit.fields.operation"), title: t("audit.fields.operation"),
dataIndex: "operation", dataIndex: "operation",
key: "operation", key: "operation",
width: "10%", width: "10%",
sorter: (a, b) => alphaSort(a.operation, b.operation), sorter: (a, b) => alphaSort(a.operation, b.operation),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "operation" && state.sortedInfo.order, state.sortedInfo.columnKey === "operation" && state.sortedInfo.order,
}, },
{ {
title: t("audit.fields.values"), title: t("audit.fields.values"),
dataIndex: " old_val", dataIndex: " old_val",
key: " old_val", key: " old_val",
width: "10%", width: "10%",
render: (text, record) => ( render: (text, record) => (
<AuditTrailValuesComponent <AuditTrailValuesComponent
oldV={record.old_val} oldV={record.old_val}
newV={record.new_val} newV={record.new_val}
/>
),
},
{
title: t("audit.fields.useremail"),
dataIndex: "useremail",
key: "useremail",
width: "10%",
sorter: (a, b) => alphaSort(a.useremail, b.useremail),
sortOrder:
state.sortedInfo.columnKey === "useremail" && state.sortedInfo.order,
},
];
const formItemLayout = {
labelCol: {
xs: {span: 12},
sm: {span: 5},
},
wrapperCol: {
xs: {span: 24},
sm: {span: 12},
},
};
const handleTableChange = (pagination, filters, sorter) => {
setState({...state, filteredInfo: filters, sortedInfo: sorter});
};
return (
<Table
{...formItemLayout}
loading={loading}
pagination={{position: "top", defaultPageSize: pageLimit}}
columns={columns}
rowKey="id"
dataSource={data}
onChange={handleTableChange}
/> />
), );
},
{
title: t("audit.fields.useremail"),
dataIndex: "useremail",
key: "useremail",
width: "10%",
sorter: (a, b) => alphaSort(a.useremail, b.useremail),
sortOrder:
state.sortedInfo.columnKey === "useremail" && state.sortedInfo.order,
},
];
const formItemLayout = {
labelCol: {
xs: { span: 12 },
sm: { span: 5 },
},
wrapperCol: {
xs: { span: 24 },
sm: { span: 12 },
},
};
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
return (
<Table
{...formItemLayout}
loading={loading}
pagination={{ position: "top", defaultPageSize: pageLimit }}
columns={columns}
rowKey="id"
dataSource={data}
onChange={handleTableChange}
/>
);
} }

View File

@@ -1,40 +1,40 @@
import React from "react"; import React from "react";
import AuditTrailListComponent from "./audit-trail-list.component"; import AuditTrailListComponent from "./audit-trail-list.component";
import { useQuery } from "@apollo/client"; import {useQuery} from "@apollo/client";
import { QUERY_AUDIT_TRAIL } from "../../graphql/audit_trail.queries"; import {QUERY_AUDIT_TRAIL} from "../../graphql/audit_trail.queries";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import EmailAuditTrailListComponent from "./email-audit-trail-list.component"; import EmailAuditTrailListComponent from "./email-audit-trail-list.component";
import { Card, Row } from "antd"; import {Card, Row} from "antd";
export default function AuditTrailListContainer({ recordId }) { export default function AuditTrailListContainer({recordId}) {
const { loading, error, data } = useQuery(QUERY_AUDIT_TRAIL, { const {loading, error, data} = useQuery(QUERY_AUDIT_TRAIL, {
variables: { id: recordId }, variables: {id: recordId},
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
}); });
logImEXEvent("audittrail_view", { recordId }); logImEXEvent("audittrail_view", {recordId});
return ( return (
<div> <div>
{error ? ( {error ? (
<AlertComponent type="error" message={error.message} /> <AlertComponent type="error" message={error.message}/>
) : ( ) : (
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
<Card> <Card>
<AuditTrailListComponent <AuditTrailListComponent
loading={loading} loading={loading}
data={data ? data.audit_trail : []} data={data ? data.audit_trail : []}
/> />
</Card> </Card>
<Card> <Card>
<EmailAuditTrailListComponent <EmailAuditTrailListComponent
loading={loading} loading={loading}
data={data ? data.audit_trail : []} data={data ? data.audit_trail : []}
/> />
</Card> </Card>
</Row> </Row>
)} )}
</div> </div>
); );
} }

View File

@@ -1,64 +1,64 @@
import { Table } from "antd"; import {Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { DateTimeFormatter } from "../../utils/DateFormatter"; import {DateTimeFormatter} from "../../utils/DateFormatter";
import { alphaSort } from "../../utils/sorters"; import {alphaSort} from "../../utils/sorters";
import {pageLimit} from "../../utils/config"; import {pageLimit} from "../../utils/config";
export default function EmailAuditTrailListComponent({ loading, data }) { export default function EmailAuditTrailListComponent({loading, data}) {
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
filteredInfo: {}, filteredInfo: {},
}); });
const { t } = useTranslation(); const {t} = useTranslation();
const columns = [ const columns = [
{ {
title: t("audit.fields.created"), title: t("audit.fields.created"),
dataIndex: " created", dataIndex: " created",
key: " created", key: " created",
width: "10%", width: "10%",
render: (text, record) => ( render: (text, record) => (
<DateTimeFormatter>{record.created}</DateTimeFormatter> <DateTimeFormatter>{record.created}</DateTimeFormatter>
), ),
sorter: (a, b) => a.created - b.created, sorter: (a, b) => a.created - b.created,
sortOrder: sortOrder:
state.sortedInfo.columnKey === "created" && state.sortedInfo.order, state.sortedInfo.columnKey === "created" && state.sortedInfo.order,
}, },
{ {
title: t("audit.fields.useremail"), title: t("audit.fields.useremail"),
dataIndex: "useremail", dataIndex: "useremail",
key: "useremail", key: "useremail",
width: "10%", width: "10%",
sorter: (a, b) => alphaSort(a.useremail, b.useremail), sorter: (a, b) => alphaSort(a.useremail, b.useremail),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "useremail" && state.sortedInfo.order, state.sortedInfo.columnKey === "useremail" && state.sortedInfo.order,
}, },
]; ];
const formItemLayout = { const formItemLayout = {
labelCol: { labelCol: {
xs: { span: 12 }, xs: {span: 12},
sm: { span: 5 }, sm: {span: 5},
}, },
wrapperCol: { wrapperCol: {
xs: { span: 24 }, xs: {span: 24},
sm: { span: 12 }, sm: {span: 12},
}, },
}; };
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({...state, filteredInfo: filters, sortedInfo: sorter});
}; };
return ( return (
<Table <Table
{...formItemLayout} {...formItemLayout}
loading={loading} loading={loading}
pagination={{ position: "top", defaultPageSize: pageLimit }} pagination={{position: "top", defaultPageSize: pageLimit}}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
dataSource={data} dataSource={data}
onChange={handleTableChange} onChange={handleTableChange}
/> />
); );
} }

View File

@@ -1,29 +1,30 @@
import React from "react"; import React from "react";
import { List } from "antd"; import {List} from "antd";
import Icon from "@ant-design/icons"; import Icon from "@ant-design/icons";
import { FaArrowRight } from "react-icons/fa"; import {FaArrowRight} from "react-icons/fa";
export default function AuditTrailValuesComponent({ oldV, newV }) {
if (!oldV && !newV) return <div></div>; export default function AuditTrailValuesComponent({oldV, newV}) {
if (!oldV && !newV) return <div></div>;
if (!oldV && newV)
return (
<List style={{width: "800px"}} bordered size='small'>
{Object.keys(newV).map((key, idx) => (
<List.Item key={idx} value={key}>
{key}: {JSON.stringify(newV[key])}
</List.Item>
))}
</List>
);
if (!oldV && newV)
return ( return (
<List style={{ width: "800px" }} bordered size='small'> <List style={{width: "800px"}} bordered size='small'>
{Object.keys(newV).map((key, idx) => ( {Object.keys(oldV).map((key, idx) => (
<List.Item key={idx} value={key}> <List.Item key={idx}>
{key}: {JSON.stringify(newV[key])} {key}: {oldV[key]} <Icon component={FaArrowRight}/>
</List.Item> {JSON.stringify(newV[key])}
))} </List.Item>
</List> ))}
</List>
); );
return (
<List style={{ width: "800px" }} bordered size='small'>
{Object.keys(oldV).map((key, idx) => (
<List.Item key={idx}>
{key}: {oldV[key]} <Icon component={FaArrowRight} />
{JSON.stringify(newV[key])}
</List.Item>
))}
</List>
);
} }

View File

@@ -1,22 +1,23 @@
import { Tag, Popover } from "antd"; import {Popover, Tag} from "antd";
import React from "react"; import React from "react";
import Barcode from "react-barcode"; import Barcode from "react-barcode";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
export default function BarcodePopupComponent({ value, children }) {
const { t } = useTranslation(); export default function BarcodePopupComponent({value, children}) {
return ( const {t} = useTranslation();
<div> return (
<Popover <div>
content={ <Popover
<Barcode content={
value={value || ""} <Barcode
background="transparent" value={value || ""}
displayValue={false} background="transparent"
/> displayValue={false}
} />
> }
{children ? children : <Tag>{t("general.labels.barcode")}</Tag>} >
</Popover> {children ? children : <Tag>{t("general.labels.barcode")}</Tag>}
</div> </Popover>
); </div>
);
} }

View File

@@ -1,135 +1,136 @@
import { Checkbox, Form, Skeleton, Typography } from "antd"; import {Checkbox, Form, Skeleton, Typography} from "antd";
import React, { useEffect } from "react"; import React, {useEffect} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component"; import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
import "./bill-cm-returns-table.styles.scss"; import "./bill-cm-returns-table.styles.scss";
export default function BillCmdReturnsTableComponent({ export default function BillCmdReturnsTableComponent({
form, form,
returnLoading, returnLoading,
returnData, returnData,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
useEffect(() => { useEffect(() => {
if (returnData) { if (returnData) {
form.setFieldsValue({ form.setFieldsValue({
outstanding_returns: returnData.parts_order_lines, outstanding_returns: returnData.parts_order_lines,
}); });
}
}, [returnData, form]);
return (
<Form.Item
shouldUpdate={(prev, cur) =>
prev.jobid !== cur.jobid ||
prev.is_credit_memo !== cur.is_credit_memo ||
prev.vendorid !== cur.vendorid
}
noStyle
>
{() => {
const isReturn = form.getFieldValue("is_credit_memo");
if (!isReturn) {
return null;
} }
}, [returnData, form]);
if (returnLoading) return <Skeleton />; return (
<Form.Item
shouldUpdate={(prev, cur) =>
prev.jobid !== cur.jobid ||
prev.is_credit_memo !== cur.is_credit_memo ||
prev.vendorid !== cur.vendorid
}
noStyle
>
{() => {
const isReturn = form.getFieldValue("is_credit_memo");
return ( if (!isReturn) {
<Form.List name="outstanding_returns"> return null;
{(fields, { add, remove, move }) => { }
return (
<>
<Typography.Title level={4}>
{t("bills.labels.creditsnotreceived")}
</Typography.Title>
<table className="bill-cm-returns-table">
<thead>
<tr>
<th>{t("parts_orders.fields.line_desc")}</th>
<th>{t("parts_orders.fields.part_type")}</th>
<th>{t("parts_orders.fields.quantity")}</th>
<th>{t("parts_orders.fields.act_price")}</th>
<th>{t("parts_orders.fields.cost")}</th>
<th>{t("parts_orders.labels.mark_as_received")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td> if (returnLoading) return <Skeleton/>;
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}part_type`}
name={[field.name, "part_type"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}act_price`}
name={[field.name, "act_price"]}
>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}cost`}
name={[field.name, "cost"]}
>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td> return (
<Form.Item <Form.List name="outstanding_returns">
span={2} {(fields, {add, remove, move}) => {
//label={t("joblines.fields.mod_lb_hrs")} return (
key={`${index}cm_received`} <>
name={[field.name, "cm_received"]} <Typography.Title level={4}>
valuePropName="checked" {t("bills.labels.creditsnotreceived")}
> </Typography.Title>
<Checkbox /> <table className="bill-cm-returns-table">
</Form.Item> <thead>
</td> <tr>
</tr> <th>{t("parts_orders.fields.line_desc")}</th>
))} <th>{t("parts_orders.fields.part_type")}</th>
</tbody> <th>{t("parts_orders.fields.quantity")}</th>
</table> <th>{t("parts_orders.fields.act_price")}</th>
</> <th>{t("parts_orders.fields.cost")}</th>
); <th>{t("parts_orders.labels.mark_as_received")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}part_type`}
name={[field.name, "part_type"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}act_price`}
name={[field.name, "act_price"]}
>
<ReadOnlyFormItemComponent type="currency"/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}cost`}
name={[field.name, "cost"]}
>
<ReadOnlyFormItemComponent type="currency"/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}cm_received`}
name={[field.name, "cm_received"]}
valuePropName="checked"
>
<Checkbox/>
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}}
</Form.List>
);
}} }}
</Form.List> </Form.Item>
); );
}}
</Form.Item>
);
} }

View File

@@ -1,19 +1,19 @@
.bill-cm-returns-table { .bill-cm-returns-table {
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
th, th,
td { td {
padding: 8px; padding: 8px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
.ant-form-item { .ant-form-item {
margin-bottom: 0px !important; margin-bottom: 0px !important;
}
} }
}
tr:hover { tr:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
} }

View File

@@ -1,80 +1,80 @@
import { DeleteFilled } from "@ant-design/icons"; import {DeleteFilled} from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { Button, notification, Popconfirm } from "antd"; import {Button, notification, Popconfirm} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { DELETE_BILL } from "../../graphql/bills.queries"; import {DELETE_BILL} from "../../graphql/bills.queries";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
export default function BillDeleteButton({ bill, callback }) { export default function BillDeleteButton({bill, callback}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const {t} = useTranslation();
const [deleteBill] = useMutation(DELETE_BILL); const [deleteBill] = useMutation(DELETE_BILL);
const handleDelete = async () => { const handleDelete = async () => {
setLoading(true); setLoading(true);
const result = await deleteBill({ const result = await deleteBill({
variables: { billId: bill.id }, variables: {billId: bill.id},
update(cache, { errors }) { update(cache, {errors}) {
if (errors) return; if (errors) return;
cache.modify({ cache.modify({
fields: { fields: {
bills(existingBills, { readField }) { bills(existingBills, {readField}) {
return existingBills.filter( return existingBills.filter(
(billref) => bill.id !== readField("id", billref) (billref) => bill.id !== readField("id", billref)
); );
},
search_bills(existingBills, {readField}) {
return existingBills.filter(
(billref) => bill.id !== readField("id", billref)
);
},
},
});
}, },
search_bills(existingBills, { readField }) {
return existingBills.filter(
(billref) => bill.id !== readField("id", billref)
);
},
},
}); });
},
});
if (!!!result.errors) { if (!!!result.errors) {
notification["success"]({ message: t("bills.successes.deleted") }); notification["success"]({message: t("bills.successes.deleted")});
if (callback && typeof callback === "function") callback(bill.id); if (callback && typeof callback === "function") callback(bill.id);
} else { } else {
//Check if it's an fkey violation. //Check if it's an fkey violation.
const error = JSON.stringify(result.errors); const error = JSON.stringify(result.errors);
if (error.toLowerCase().includes("inventory_billid_fkey")) { if (error.toLowerCase().includes("inventory_billid_fkey")) {
notification["error"]({ notification["error"]({
message: t("bills.errors.deleting", { message: t("bills.errors.deleting", {
error: t("bills.errors.existinginventoryline"), error: t("bills.errors.existinginventoryline"),
}), }),
}); });
} else { } else {
notification["error"]({ notification["error"]({
message: t("bills.errors.deleting", { message: t("bills.errors.deleting", {
error: JSON.stringify(result.errors), error: JSON.stringify(result.errors),
}), }),
}); });
} }
} }
setLoading(false); setLoading(false);
}; };
return ( return (
<RbacWrapper action="bills:delete" noauth={<></>}> <RbacWrapper action="bills:delete" noauth={<></>}>
<Popconfirm <Popconfirm
disabled={bill.exported} disabled={bill.exported}
onConfirm={handleDelete} onConfirm={handleDelete}
title={t("bills.labels.deleteconfirm")} title={t("bills.labels.deleteconfirm")}
> >
<Button <Button
disabled={bill.exported} disabled={bill.exported}
// onClick={handleDelete} // onClick={handleDelete}
loading={loading} loading={loading}
> >
<DeleteFilled /> <DeleteFilled/>
</Button> </Button>
</Popconfirm> </Popconfirm>
</RbacWrapper> </RbacWrapper>
); );
} }

View File

@@ -1,21 +1,17 @@
import { useMutation, useQuery } from "@apollo/client"; import {useMutation, useQuery} from "@apollo/client";
import { Button, Form, PageHeader, Popconfirm, Space } from "antd"; import {Button, Form, Popconfirm, Space} from "antd";
import moment from "moment"; import dayjs from "../../utils/day";
import queryString from "query-string"; import queryString from "query-string";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { useLocation } from "react-router-dom"; import {useLocation} from "react-router-dom";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { import {DELETE_BILL_LINE, INSERT_NEW_BILL_LINES, UPDATE_BILL_LINE} from "../../graphql/bill-lines.queries";
DELETE_BILL_LINE, import {QUERY_BILL_BY_PK, UPDATE_BILL} from "../../graphql/bills.queries";
INSERT_NEW_BILL_LINES, import {insertAuditTrail} from "../../redux/application/application.actions";
UPDATE_BILL_LINE, import {setModalContext} from "../../redux/modals/modals.actions";
} from "../../graphql/bill-lines.queries"; import {selectBodyshop} from "../../redux/user/user.selectors";
import { QUERY_BILL_BY_PK, UPDATE_BILL } from "../../graphql/bills.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import BillFormContainer from "../bill-form/bill-form.container"; import BillFormContainer from "../bill-form/bill-form.container";
@@ -26,227 +22,226 @@ import JobDocumentsGallery from "../jobs-documents-gallery/jobs-documents-galler
import JobsDocumentsLocalGallery from "../jobs-documents-local-gallery/jobs-documents-local-gallery.container"; import JobsDocumentsLocalGallery from "../jobs-documents-local-gallery/jobs-documents-local-gallery.container";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component";
import BillDetailEditReturn from "./bill-detail-edit-return.component"; import BillDetailEditReturn from "./bill-detail-edit-return.component";
import {PageHeader} from "@ant-design/pro-layout";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => setPartsOrderContext: (context) =>
dispatch(setModalContext({ context: context, modal: "partsOrder" })), dispatch(setModalContext({context: context, modal: "partsOrder"})),
insertAuditTrail: ({ jobid, operation }) => insertAuditTrail: ({jobid, operation}) =>
dispatch(insertAuditTrail({ jobid, operation })), dispatch(insertAuditTrail({jobid, operation})),
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillDetailEditcontainer); )(BillDetailEditcontainer);
export function BillDetailEditcontainer({ export function BillDetailEditcontainer({setPartsOrderContext, insertAuditTrail, bodyshop,}) {
setPartsOrderContext, const search = queryString.parse(useLocation().search);
insertAuditTrail,
bodyshop,
}) {
const search = queryString.parse(useLocation().search);
const { t } = useTranslation(); const {t} = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [visible, setVisible] = useState(false); const [open, setOpen] = useState(false);
const [updateLoading, setUpdateLoading] = useState(false); const [updateLoading, setUpdateLoading] = useState(false);
const [update_bill] = useMutation(UPDATE_BILL); const [update_bill] = useMutation(UPDATE_BILL);
const [insertBillLine] = useMutation(INSERT_NEW_BILL_LINES); const [insertBillLine] = useMutation(INSERT_NEW_BILL_LINES);
const [updateBillLine] = useMutation(UPDATE_BILL_LINE); const [updateBillLine] = useMutation(UPDATE_BILL_LINE);
const [deleteBillLine] = useMutation(DELETE_BILL_LINE); const [deleteBillLine] = useMutation(DELETE_BILL_LINE);
const { loading, error, data, refetch } = useQuery(QUERY_BILL_BY_PK, { const {loading, error, data, refetch} = useQuery(QUERY_BILL_BY_PK, {
variables: { billid: search.billid }, variables: {billid: search.billid},
skip: !!!search.billid, skip: !!!search.billid,
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
});
const handleSave = () => {
//It's got a previously deducted bill line!
if (
data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length >
0
)
setVisible(true);
else {
form.submit();
}
};
const handleFinish = async (values) => {
setUpdateLoading(true);
//let adjustmentsToInsert = {};
const { billlines, upload, ...bill } = values;
const updates = [];
updates.push(
update_bill({
variables: { billId: search.billid, bill: bill },
})
);
billlines.forEach((l) => {
delete l.selected;
}); });
//Find bill lines that were deleted. // ... rest of the code remains the same
const deletedJobLines = [];
data.bills_by_pk.billlines.forEach((a) => { const handleSave = () => {
const matchingRecord = billlines.find((b) => b.id === a.id); //It's got a previously deducted bill line!
if (!matchingRecord) { if (
deletedJobLines.push(a); data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
} form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length >
}); 0
)
setOpen(true);
else {
form.submit();
}
};
deletedJobLines.forEach((d) => { const handleFinish = async (values) => {
updates.push(deleteBillLine({ variables: { id: d.id } })); setUpdateLoading(true);
}); //let adjustmentsToInsert = {};
billlines.forEach((billline) => { const {billlines, upload, ...bill} = values;
const { deductedfromlbr, inventories, jobline, ...il } = billline; const updates = [];
delete il.__typename;
if (il.id) {
updates.push( updates.push(
updateBillLine({ update_bill({
variables: { variables: {billId: search.billid, bill: bill},
billLineId: il.id, })
billLine: {
...il,
deductedfromlbr: deductedfromlbr,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
},
})
); );
} else {
//It's a new line, have to insert it.
updates.push(
insertBillLine({
variables: {
billLines: [
{
...il,
deductedfromlbr: deductedfromlbr,
billid: search.billid,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
],
},
})
);
}
});
await Promise.all(updates); billlines.forEach((l) => {
delete l.selected;
});
insertAuditTrail({ //Find bill lines that were deleted.
jobid: bill.jobid, const deletedJobLines = [];
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
});
await refetch(); data.bills_by_pk.billlines.forEach((a) => {
form.setFieldsValue(transformData(data)); const matchingRecord = billlines.find((b) => b.id === a.id);
form.resetFields(); if (!matchingRecord) {
setVisible(false); deletedJobLines.push(a);
setUpdateLoading(false); }
}; });
if (error) return <AlertComponent message={error.message} type="error" />; deletedJobLines.forEach((d) => {
if (!search.billid) return <></>; //<div>{t("bills.labels.noneselected")}</div>; updates.push(deleteBillLine({variables: {id: d.id}}));
});
const exported = data && data.bills_by_pk && data.bills_by_pk.exported; billlines.forEach((billline) => {
const {deductedfromlbr, inventories, jobline, ...il} = billline;
delete il.__typename;
return ( if (il.id) {
<> updates.push(
{loading && <LoadingSkeleton />} updateBillLine({
{data && ( variables: {
billLineId: il.id,
billLine: {
...il,
deductedfromlbr: deductedfromlbr,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
},
})
);
} else {
//It's a new line, have to insert it.
updates.push(
insertBillLine({
variables: {
billLines: [
{
...il,
deductedfromlbr: deductedfromlbr,
billid: search.billid,
joblineid: il.joblineid === "noline" ? null : il.joblineid,
},
],
},
})
);
}
});
await Promise.all(updates);
insertAuditTrail({
jobid: bill.jobid,
billid: search.billid,
operation: AuditTrailMapping.billupdated(bill.invoice_number),
});
await refetch();
form.setFieldsValue(transformData(data));
form.resetFields();
setOpen(false);
setUpdateLoading(false);
};
if (error) return <AlertComponent message={error.message} type="error"/>;
if (!search.billid) return <></>; //<div>{t("bills.labels.noneselected")}</div>;
const exported = data && data.bills_by_pk && data.bills_by_pk.exported;
return (
<> <>
<PageHeader {loading && <LoadingSkeleton/>}
title={ {data && (
data && <>
`${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}` <PageHeader
} title={
extra={ data &&
<Space> `${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}`
<BillDetailEditReturn data={data} /> }
<BillPrintButton billid={search.billid} /> extra={
<Popconfirm <Space>
visible={visible} <BillDetailEditReturn data={data}/>
onConfirm={() => form.submit()} <BillPrintButton billid={search.billid}/>
onCancel={() => setVisible(false)} <Popconfirm
okButtonProps={{ loading: updateLoading }} open={open}
title={t("bills.labels.editadjwarning")} onConfirm={() => form.submit()}
> onCancel={() => setOpen(false)}
<Button okButtonProps={{loading: updateLoading}}
htmlType="submit" title={t("bills.labels.editadjwarning")}
disabled={exported} >
onClick={handleSave} <Button
loading={updateLoading} htmlType="submit"
type="primary" disabled={exported}
> onClick={handleSave}
{t("general.actions.save")} loading={updateLoading}
</Button> type="primary"
</Popconfirm> >
<BillReeportButtonComponent bill={data && data.bills_by_pk} /> {t("general.actions.save")}
<BillMarkExportedButton bill={data && data.bills_by_pk} /> </Button>
</Space> </Popconfirm>
} <BillReeportButtonComponent bill={data && data.bills_by_pk}/>
/> <BillMarkExportedButton bill={data && data.bills_by_pk}/>
<Form </Space>
form={form} }
onFinish={handleFinish} />
initialValues={transformData(data)} <Form
layout="vertical" form={form}
> onFinish={handleFinish}
<BillFormContainer form={form} billEdit disabled={exported} /> initialValues={transformData(data)}
layout="vertical"
>
<BillFormContainer form={form} billEdit disabled={exported}/>
{bodyshop.uselocalmediaserver ? ( {bodyshop.uselocalmediaserver ? (
<JobsDocumentsLocalGallery <JobsDocumentsLocalGallery
job={{ id: data ? data.bills_by_pk.jobid : null }} job={{id: data ? data.bills_by_pk.jobid : null}}
invoice_number={data ? data.bills_by_pk.invoice_number : null} invoice_number={data ? data.bills_by_pk.invoice_number : null}
vendorid={data ? data.bills_by_pk.vendorid : null} vendorid={data ? data.bills_by_pk.vendorid : null}
/> />
) : ( ) : (
<JobDocumentsGallery <JobDocumentsGallery
jobId={data ? data.bills_by_pk.jobid : null} jobId={data ? data.bills_by_pk.jobid : null}
billId={search.billid} billId={search.billid}
documentsList={data ? data.bills_by_pk.documents : []} documentsList={data ? data.bills_by_pk.documents : []}
billsCallback={refetch} billsCallback={refetch}
/> />
)}
</Form>
</>
)} )}
</Form>
</> </>
)} );
</>
);
} }
const transformData = (data) => { const transformData = (data) => {
return data return data
? { ? {
...data.bills_by_pk, ...data.bills_by_pk,
billlines: data.bills_by_pk.billlines.map((i) => { billlines: data.bills_by_pk.billlines.map((i) => {
return { return {
...i, ...i,
joblineid: !!i.joblineid ? i.joblineid : "noline", joblineid: !!i.joblineid ? i.joblineid : "noline",
applicable_taxes: { applicable_taxes: {
federal: federal:
(i.applicable_taxes && i.applicable_taxes.federal) || false, (i.applicable_taxes && i.applicable_taxes.federal) || false,
state: (i.applicable_taxes && i.applicable_taxes.state) || false, state: (i.applicable_taxes && i.applicable_taxes.state) || false,
local: (i.applicable_taxes && i.applicable_taxes.local) || false, local: (i.applicable_taxes && i.applicable_taxes.local) || false,
}, },
}; };
}), }),
date: data.bills_by_pk ? moment(data.bills_by_pk.date) : null, date: data.bills_by_pk ? dayjs(data.bills_by_pk.date) : null,
} }
: {}; : {};
}; };

View File

@@ -1,185 +1,185 @@
import { Button, Checkbox, Form, Modal } from "antd"; import {Button, Checkbox, Form, Modal} from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React, { useEffect, useState } from "react"; import React, {useEffect, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { useHistory, useLocation } from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { insertAuditTrail } from "../../redux/application/application.actions"; import {insertAuditTrail} from "../../redux/application/application.actions";
import { setModalContext } from "../../redux/modals/modals.actions"; import {setModalContext} from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component"; import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => setPartsOrderContext: (context) =>
dispatch(setModalContext({ context: context, modal: "partsOrder" })), dispatch(setModalContext({context: context, modal: "partsOrder"})),
insertAuditTrail: ({ jobid, operation }) => insertAuditTrail: ({jobid, operation}) =>
dispatch(insertAuditTrail({ jobid, operation })), dispatch(insertAuditTrail({jobid, operation})),
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillDetailEditReturn); )(BillDetailEditReturn);
export function BillDetailEditReturn({ export function BillDetailEditReturn({
setPartsOrderContext, setPartsOrderContext,
insertAuditTrail, insertAuditTrail,
bodyshop, bodyshop,
data, data,
disabled, disabled,
}) { }) {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const history = useHistory(); const history = useNavigate();
const { t } = useTranslation(); const {t} = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [visible, setVisible] = useState(false); const [open, setOpen] = useState(false);
const handleFinish = ({ billlines }) => { const handleFinish = ({billlines}) => {
const selectedLines = billlines.filter((l) => l.selected).map((l) => l.id); const selectedLines = billlines.filter((l) => l.selected).map((l) => l.id);
setPartsOrderContext({ setPartsOrderContext({
actions: {}, actions: {},
context: { context: {
jobId: data.bills_by_pk.jobid, jobId: data.bills_by_pk.jobid,
vendorId: data.bills_by_pk.vendorid, vendorId: data.bills_by_pk.vendorid,
returnFromBill: data.bills_by_pk.id, returnFromBill: data.bills_by_pk.id,
invoiceNumber: data.bills_by_pk.invoice_number, invoiceNumber: data.bills_by_pk.invoice_number,
linesToOrder: data.bills_by_pk.billlines linesToOrder: data.bills_by_pk.billlines
.filter((l) => selectedLines.includes(l.id)) .filter((l) => selectedLines.includes(l.id))
.map((i) => { .map((i) => {
return { return {
line_desc: i.line_desc, line_desc: i.line_desc,
// db_price: i.actual_price, // db_price: i.actual_price,
act_price: i.actual_price, act_price: i.actual_price,
cost: i.actual_cost, cost: i.actual_cost,
quantity: i.quantity, quantity: i.quantity,
joblineid: i.joblineid, joblineid: i.joblineid,
oem_partno: i.jobline && i.jobline.oem_partno, oem_partno: i.jobline && i.jobline.oem_partno,
part_type: i.jobline && i.jobline.part_type, part_type: i.jobline && i.jobline.part_type,
}; };
}), }),
isReturn: true, isReturn: true,
}, },
}); });
delete search.billid; delete search.billid;
history.push({ search: queryString.stringify(search) }); history({search: queryString.stringify(search)});
setVisible(false); setOpen(false);
}; };
useEffect(() => { useEffect(() => {
if (visible === false) form.resetFields(); if (open === false) form.resetFields();
}, [visible, form]); }, [open, form]);
return ( return (
<> <>
<Modal <Modal
visible={visible} open={open}
onCancel={() => setVisible(false)} onCancel={() => setOpen(false)}
destroyOnClose destroyOnClose
title={t("bills.actions.return")} title={t("bills.actions.return")}
onOk={() => form.submit()} onOk={() => form.submit()}
> >
<Form <Form
initialValues={data && data.bills_by_pk} initialValues={data && data.bills_by_pk}
onFinish={handleFinish} onFinish={handleFinish}
form={form} form={form}
> >
<Form.List name={["billlines"]}> <Form.List name={["billlines"]}>
{(fields, { add, remove, move }) => { {(fields, {add, remove, move}) => {
return ( return (
<table style={{ tableLayout: "auto", width: "100%" }}> <table style={{tableLayout: "auto", width: "100%"}}>
<thead> <thead>
<tr> <tr>
<td> <td>
<Checkbox <Checkbox
onChange={(e) => { onChange={(e) => {
form.setFieldsValue({ form.setFieldsValue({
billlines: form billlines: form
.getFieldsValue() .getFieldsValue()
.billlines.map((b) => ({ .billlines.map((b) => ({
...b, ...b,
selected: e.target.checked, selected: e.target.checked,
})), })),
}); });
}} }}
/> />
</td> </td>
<td>{t("billlines.fields.line_desc")}</td> <td>{t("billlines.fields.line_desc")}</td>
<td>{t("billlines.fields.quantity")}</td> <td>{t("billlines.fields.quantity")}</td>
<td>{t("billlines.fields.actual_price")}</td> <td>{t("billlines.fields.actual_price")}</td>
<td>{t("billlines.fields.actual_cost")}</td> <td>{t("billlines.fields.actual_cost")}</td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{fields.map((field, index) => ( {fields.map((field, index) => (
<tr key={field.key}> <tr key={field.key}>
<td> <td>
<Form.Item <Form.Item
// label={t("joblines.fields.selected")} // label={t("joblines.fields.selected")}
key={`${index}selected`} key={`${index}selected`}
name={[field.name, "selected"]} name={[field.name, "selected"]}
valuePropName="checked" valuePropName="checked"
> >
<Checkbox /> <Checkbox/>
</Form.Item> </Form.Item>
</td> </td>
<td> <td>
<Form.Item <Form.Item
// label={t("joblines.fields.line_desc")} // label={t("joblines.fields.line_desc")}
key={`${index}line_desc`} key={`${index}line_desc`}
name={[field.name, "line_desc"]} name={[field.name, "line_desc"]}
> >
<ReadOnlyFormItemComponent /> <ReadOnlyFormItemComponent/>
</Form.Item> </Form.Item>
</td> </td>
<td> <td>
<Form.Item <Form.Item
// label={t("joblines.fields.quantity")} // label={t("joblines.fields.quantity")}
key={`${index}quantity`} key={`${index}quantity`}
name={[field.name, "quantity"]} name={[field.name, "quantity"]}
> >
<ReadOnlyFormItemComponent /> <ReadOnlyFormItemComponent/>
</Form.Item> </Form.Item>
</td> </td>
<td> <td>
<Form.Item <Form.Item
// label={t("joblines.fields.actual_price")} // label={t("joblines.fields.actual_price")}
key={`${index}actual_price`} key={`${index}actual_price`}
name={[field.name, "actual_price"]} name={[field.name, "actual_price"]}
> >
<ReadOnlyFormItemComponent type="currency" /> <ReadOnlyFormItemComponent type="currency"/>
</Form.Item> </Form.Item>
</td> </td>
<td> <td>
<Form.Item <Form.Item
// label={t("joblines.fields.actual_cost")} // label={t("joblines.fields.actual_cost")}
key={`${index}actual_cost`} key={`${index}actual_cost`}
name={[field.name, "actual_cost"]} name={[field.name, "actual_cost"]}
> >
<ReadOnlyFormItemComponent type="currency" /> <ReadOnlyFormItemComponent type="currency"/>
</Form.Item> </Form.Item>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
); );
}} }}
</Form.List> </Form.List>
</Form> </Form>
</Modal> </Modal>
<Button <Button
disabled={data.bills_by_pk.is_credit_memo || disabled} disabled={data.bills_by_pk.is_credit_memo || disabled}
onClick={() => { onClick={() => {
setVisible(true); setOpen(true);
}} }}
> >
{t("bills.actions.return")} {t("bills.actions.return")}
</Button> </Button>
</> </>
); );
} }

View File

@@ -1,40 +1,40 @@
import { Drawer, Grid } from "antd"; import {Drawer, Grid} from "antd";
import queryString from "query-string"; import queryString from "query-string";
import React from "react"; import React from "react";
import { useHistory, useLocation } from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import BillDetailEditComponent from "./bill-detail-edit-component"; import BillDetailEditComponent from "./bill-detail-edit-component";
export default function BillDetailEditcontainer() { export default function BillDetailEditcontainer() {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const history = useHistory(); const history = useNavigate();
const selectedBreakpoint = Object.entries(Grid.useBreakpoint()) const selectedBreakpoint = Object.entries(Grid.useBreakpoint())
.filter((screen) => !!screen[1]) .filter((screen) => !!screen[1])
.slice(-1)[0]; .slice(-1)[0];
const bpoints = { const bpoints = {
xs: "100%", xs: "100%",
sm: "100%", sm: "100%",
md: "100%", md: "100%",
lg: "100%", lg: "100%",
xl: "90%", xl: "90%",
xxl: "90%", xxl: "90%",
}; };
const drawerPercentage = selectedBreakpoint const drawerPercentage = selectedBreakpoint
? bpoints[selectedBreakpoint[0]] ? bpoints[selectedBreakpoint[0]]
: "100%"; : "100%";
return ( return (
<Drawer <Drawer
width={drawerPercentage} width={drawerPercentage}
onClose={() => { onClose={() => {
delete search.billid; delete search.billid;
history.push({ search: queryString.stringify(search) }); history({search: queryString.stringify(search)});
}} }}
destroyOnClose destroyOnClose
visible={search.billid} open={search.billid}
> >
<BillDetailEditComponent /> <BillDetailEditComponent/>
</Drawer> </Drawer>
); );
} }

View File

@@ -1,470 +1,466 @@
import { useApolloClient, useMutation } from "@apollo/client"; import {useApolloClient, useMutation} from "@apollo/client";
import { useTreatments } from "@splitsoftware/splitio-react"; import {useSplitTreatments} from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Modal, Space, notification } from "antd"; import {Button, Checkbox, Form, Modal, notification, Space} from "antd";
import _ from "lodash"; import _ from "lodash";
import React, { useEffect, useMemo, useState } from "react"; import React, {useEffect, useMemo, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { INSERT_NEW_BILL } from "../../graphql/bills.queries"; import {INSERT_NEW_BILL} from "../../graphql/bills.queries";
import { UPDATE_INVENTORY_LINES } from "../../graphql/inventory.queries"; import {UPDATE_INVENTORY_LINES} from "../../graphql/inventory.queries";
import { UPDATE_JOB_LINE } from "../../graphql/jobs-lines.queries"; import {UPDATE_JOB_LINE} from "../../graphql/jobs-lines.queries";
import { import {QUERY_JOB_LBR_ADJUSTMENTS, UPDATE_JOB,} from "../../graphql/jobs.queries";
QUERY_JOB_LBR_ADJUSTMENTS, import {MUTATION_MARK_RETURN_RECEIVED} from "../../graphql/parts-orders.queries";
UPDATE_JOB, import {insertAuditTrail} from "../../redux/application/application.actions";
} from "../../graphql/jobs.queries"; import {toggleModalVisible} from "../../redux/modals/modals.actions";
import { MUTATION_MARK_RETURN_RECEIVED } from "../../graphql/parts-orders.queries"; import {selectBillEnterModal} from "../../redux/modals/modals.selectors";
import { insertAuditTrail } from "../../redux/application/application.actions"; import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import {
selectBodyshop,
selectCurrentUser,
} from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { GenerateDocument } from "../../utils/RenderTemplate"; import {GenerateDocument} from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import {TemplateList} from "../../utils/TemplateConstants";
import confirmDialog from "../../utils/asyncConfirm"; import confirmDialog from "../../utils/asyncConfirm";
import useLocalStorage from "../../utils/useLocalStorage"; import useLocalStorage from "../../utils/useLocalStorage";
import BillFormContainer from "../bill-form/bill-form.container"; import BillFormContainer from "../bill-form/bill-form.container";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility"; import {CalculateBillTotal} from "../bill-form/bill-form.totals.utility";
import { handleUpload as handleLocalUpload } from "../documents-local-upload/documents-local-upload.utility"; import {handleUpload as handleLocalUpload} from "../documents-local-upload/documents-local-upload.utility";
import { handleUpload } from "../documents-upload/documents-upload.utility"; import {handleUpload} from "../documents-upload/documents-upload.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("billEnter")), toggleModalVisible: () => dispatch(toggleModalVisible("billEnter")),
insertAuditTrail: ({ jobid, billid, operation }) => insertAuditTrail: ({jobid, billid, operation}) =>
dispatch(insertAuditTrail({ jobid, billid, operation })), dispatch(insertAuditTrail({jobid, billid, operation})),
}); });
const Templates = TemplateList("job_special"); const Templates = TemplateList("job_special");
function BillEnterModalContainer({ function BillEnterModalContainer({
billEnterModal, billEnterModal,
toggleModalVisible, toggleModalVisible,
bodyshop, bodyshop,
currentUser, currentUser,
insertAuditTrail, insertAuditTrail,
}) { }) {
const [form] = Form.useForm(); const [form] = Form.useForm();
const { t } = useTranslation(); const {t} = useTranslation();
const [enterAgain, setEnterAgain] = useState(false); const [enterAgain, setEnterAgain] = useState(false);
const [insertBill] = useMutation(INSERT_NEW_BILL); const [insertBill] = useMutation(INSERT_NEW_BILL);
const [updateJobLines] = useMutation(UPDATE_JOB_LINE); const [updateJobLines] = useMutation(UPDATE_JOB_LINE);
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED); const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES); const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const client = useApolloClient(); const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage( const [generateLabel, setGenerateLabel] = useLocalStorage(
"enter_bill_generate_label", "enter_bill_generate_label",
false false
);
const { Enhanced_Payroll } = useTreatments(
["Enhanced_Payroll"],
{},
bodyshop.imexshopid
);
const formValues = useMemo(() => {
return {
...billEnterModal.context.bill,
//Added as a part of IO-2436 for capturing parts price changes.
billlines: billEnterModal.context?.bill?.billlines?.map((line) => ({
...line,
original_actual_price: line.actual_price,
})),
jobid:
(billEnterModal.context.job && billEnterModal.context.job.id) || null,
federal_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.federal_tax_rate) ||
0,
state_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.state_tax_rate) ||
0,
local_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.local_tax_rate) ||
0,
};
}, [billEnterModal, bodyshop]);
const handleFinish = async (values) => {
let totals = CalculateBillTotal(values);
if (totals.discrepancy.getAmount() !== 0) {
if (!(await confirmDialog(t("bills.labels.savewithdiscrepancy")))) {
return;
}
}
setLoading(true);
const {
upload,
location,
outstanding_returns,
inventory,
...remainingValues
} = values;
let adjustmentsToInsert = {};
let payrollAdjustmentsToInsert = [];
const r1 = await insertBill({
variables: {
bill: [
{
...remainingValues,
billlines: {
data:
remainingValues.billlines &&
remainingValues.billlines.map((i) => {
const {
deductedfromlbr,
lbr_adjustment,
location: lineLocation,
part_type,
create_ppc,
original_actual_price,
...restI
} = i;
if (Enhanced_Payroll.treatment === "on") {
if (
deductedfromlbr &&
true //payroll is on
) {
payrollAdjustmentsToInsert.push({
id: i.joblineid,
convertedtolbr: true,
convertedtolbr_data: {
mod_lb_hrs: lbr_adjustment.mod_lb_hrs * -1,
mod_lbr_ty: lbr_adjustment.mod_lbr_ty,
},
});
}
} else {
if (deductedfromlbr) {
adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] =
(adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] || 0) -
restI.actual_price / lbr_adjustment.rate;
}
}
return {
...restI,
deductedfromlbr: deductedfromlbr,
lbr_adjustment,
joblineid: i.joblineid === "noline" ? null : i.joblineid,
applicable_taxes: {
federal:
(i.applicable_taxes && i.applicable_taxes.federal) ||
false,
state:
(i.applicable_taxes && i.applicable_taxes.state) ||
false,
local:
(i.applicable_taxes && i.applicable_taxes.local) ||
false,
},
};
}),
},
},
],
},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
});
await Promise.all(
payrollAdjustmentsToInsert.map((li) => {
return updateJobLines({
variables: {
lineId: li.id,
line: {
convertedtolbr: li.convertedtolbr,
convertedtolbr_data: li.convertedtolbr_data,
},
},
});
})
); );
const adjKeys = Object.keys(adjustmentsToInsert); const {treatments: {Enhanced_Payroll}} = useSplitTreatments({
if (adjKeys.length > 0) { attributes: {},
//Query the adjustments, merge, and update them. names: ["Enhanced_Payroll"],
const existingAdjustments = await client.query({ splitKey: bodyshop.imexshopid,
query: QUERY_JOB_LBR_ADJUSTMENTS, });
variables: {
id: values.jobid,
},
});
const newAdjustments = _.cloneDeep( const formValues = useMemo(() => {
existingAdjustments.data.jobs_by_pk.lbr_adjustments return {
); ...billEnterModal.context.bill,
//Added as a part of IO-2436 for capturing parts price changes.
billlines: billEnterModal.context?.bill?.billlines?.map((line) => ({
...line,
original_actual_price: line.actual_price,
})),
jobid:
(billEnterModal.context.job && billEnterModal.context.job.id) || null,
federal_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.federal_tax_rate) ||
0,
state_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.state_tax_rate) ||
0,
local_tax_rate:
(bodyshop.bill_tax_rates && bodyshop.bill_tax_rates.local_tax_rate) ||
0,
};
}, [billEnterModal, bodyshop]);
adjKeys.forEach((key) => { const handleFinish = async (values) => {
newAdjustments[key] = let totals = CalculateBillTotal(values);
(newAdjustments[key] || 0) + adjustmentsToInsert[key]; if (totals.discrepancy.getAmount() !== 0) {
if (!(await confirmDialog(t("bills.labels.savewithdiscrepancy")))) {
return;
}
}
setLoading(true);
const {
upload,
location,
outstanding_returns,
inventory,
...remainingValues
} = values;
let adjustmentsToInsert = {};
let payrollAdjustmentsToInsert = [];
const r1 = await insertBill({
variables: {
bill: [
{
...remainingValues,
billlines: {
data:
remainingValues.billlines &&
remainingValues.billlines.map((i) => {
const {
deductedfromlbr,
lbr_adjustment,
location: lineLocation,
part_type,
create_ppc,
original_actual_price,
...restI
} = i;
if (Enhanced_Payroll.treatment === "on") {
if (
deductedfromlbr &&
true //payroll is on
) {
payrollAdjustmentsToInsert.push({
id: i.joblineid,
convertedtolbr: true,
convertedtolbr_data: {
mod_lb_hrs: lbr_adjustment.mod_lb_hrs * -1,
mod_lbr_ty: lbr_adjustment.mod_lbr_ty,
},
});
}
} else {
if (deductedfromlbr) {
adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] =
(adjustmentsToInsert[lbr_adjustment.mod_lbr_ty] || 0) -
restI.actual_price / lbr_adjustment.rate;
}
}
return {
...restI,
deductedfromlbr: deductedfromlbr,
lbr_adjustment,
joblineid: i.joblineid === "noline" ? null : i.joblineid,
applicable_taxes: {
federal:
(i.applicable_taxes && i.applicable_taxes.federal) ||
false,
state:
(i.applicable_taxes && i.applicable_taxes.state) ||
false,
local:
(i.applicable_taxes && i.applicable_taxes.local) ||
false,
},
};
}),
},
},
],
},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID", "GET_JOB_BY_PK"],
});
await Promise.all(
payrollAdjustmentsToInsert.map((li) => {
return updateJobLines({
variables: {
lineId: li.id,
line: {
convertedtolbr: li.convertedtolbr,
convertedtolbr_data: li.convertedtolbr_data,
},
},
});
})
);
const adjKeys = Object.keys(adjustmentsToInsert);
if (adjKeys.length > 0) {
//Query the adjustments, merge, and update them.
const existingAdjustments = await client.query({
query: QUERY_JOB_LBR_ADJUSTMENTS,
variables: {
id: values.jobid,
},
});
const newAdjustments = _.cloneDeep(
existingAdjustments.data.jobs_by_pk.lbr_adjustments
);
adjKeys.forEach((key) => {
newAdjustments[key] =
(newAdjustments[key] || 0) + adjustmentsToInsert[key];
insertAuditTrail({
jobid: values.jobid,
operation: AuditTrailMapping.jobmodifylbradj({
mod_lbr_ty: key,
hours: adjustmentsToInsert[key].toFixed(1),
}),
});
});
const jobUpdate = client.mutate({
mutation: UPDATE_JOB,
variables: {
jobId: values.jobid,
job: {lbr_adjustments: newAdjustments},
},
});
if (!!jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors),
}),
});
return;
}
}
const markPolReceived =
outstanding_returns &&
outstanding_returns.filter((o) => o.cm_received === true);
if (markPolReceived && markPolReceived.length > 0) {
const r2 = await updatePartsOrderLines({
variables: {partsLineIds: markPolReceived.map((p) => p.id)},
});
if (!!r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
message: t("parts_orders.errors.updating", {
message: JSON.stringify(r2.errors),
}),
});
}
}
if (!!r1.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
message: t("bills.errors.creating", {
message: JSON.stringify(r1.errors),
}),
});
}
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),
}),
});
}
}
//If it's not a credit memo, update the statuses.
if (!values.is_credit_memo) {
await Promise.all(
remainingValues.billlines
.filter((il) => il.joblineid !== "noline")
.map((li) => {
return updateJobLines({
variables: {
lineId: li.joblineid,
line: {
location: li.location || location,
status:
bodyshop.md_order_statuses.default_received || "Received*",
//Added parts price changes.
...(li.create_ppc &&
li.original_actual_price !== li.actual_price
? {
act_price_before_ppc: li.original_actual_price,
act_price: li.actual_price,
}
: {}),
},
},
});
})
);
}
/////////////////////////
if (upload && upload.length > 0) {
//insert Each of the documents?
if (bodyshop.uselocalmediaserver) {
upload.forEach((u) => {
handleLocalUpload({
ev: {file: u.originFileObj},
context: {
jobid: values.jobid,
invoice_number: remainingValues.invoice_number,
vendorid: remainingValues.vendorid,
},
});
});
} else {
upload.forEach((u) => {
handleUpload(
{file: u.originFileObj},
{
bodyshop: bodyshop,
uploaded_by: currentUser.email,
jobId: values.jobid,
billId: billId,
tagsArray: null,
callback: null,
}
);
});
}
}
///////////////////////////
setLoading(false);
notification["success"]({
message: t("bills.successes.created"),
});
if (generateLabel) {
GenerateDocument(
{
name: Templates.parts_invoice_label_single.key,
variables: {
id: billId,
},
},
{},
"p"
);
}
if (billEnterModal.actions.refetch) billEnterModal.actions.refetch();
insertAuditTrail({ insertAuditTrail({
jobid: values.jobid, jobid: values.jobid,
operation: AuditTrailMapping.jobmodifylbradj({ billid: billId,
mod_lbr_ty: key, operation: AuditTrailMapping.billposted(
hours: adjustmentsToInsert[key].toFixed(1), r1.data.insert_bills.returning[0].invoice_number
}), ),
}); });
});
const jobUpdate = client.mutate({ if (enterAgain) {
mutation: UPDATE_JOB, // form.resetFields();
variables: { form.setFieldsValue({
jobId: values.jobid, ...formValues,
job: { lbr_adjustments: newAdjustments }, billlines: [],
},
});
if (!!jobUpdate.errors) {
notification["error"]({
message: t("jobs.errors.saving", {
message: JSON.stringify(jobUpdate.errors),
}),
});
return;
}
}
const markPolReceived =
outstanding_returns &&
outstanding_returns.filter((o) => o.cm_received === true);
if (markPolReceived && markPolReceived.length > 0) {
const r2 = await updatePartsOrderLines({
variables: { partsLineIds: markPolReceived.map((p) => p.id) },
});
if (!!r2.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
message: t("parts_orders.errors.updating", {
message: JSON.stringify(r2.errors),
}),
});
}
}
if (!!r1.errors) {
setLoading(false);
setEnterAgain(false);
notification["error"]({
message: t("bills.errors.creating", {
message: JSON.stringify(r1.errors),
}),
});
}
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),
}),
});
}
}
//If it's not a credit memo, update the statuses.
if (!values.is_credit_memo) {
await Promise.all(
remainingValues.billlines
.filter((il) => il.joblineid !== "noline")
.map((li) => {
return updateJobLines({
variables: {
lineId: li.joblineid,
line: {
location: li.location || location,
status:
bodyshop.md_order_statuses.default_received || "Received*",
//Added parts price changes.
...(li.create_ppc &&
li.original_actual_price !== li.actual_price
? {
act_price_before_ppc: li.original_actual_price,
act_price: li.actual_price,
}
: {}),
},
},
}); });
}) form.resetFields();
); } else {
} toggleModalVisible();
}
setEnterAgain(false);
};
///////////////////////// const handleCancel = () => {
if (upload && upload.length > 0) { const r = window.confirm(t("general.labels.cancel"));
//insert Each of the documents? if (r === true) {
toggleModalVisible();
}
};
if (bodyshop.uselocalmediaserver) { useEffect(() => {
upload.forEach((u) => { if (enterAgain) form.submit();
handleLocalUpload({ }, [enterAgain, form]);
ev: { file: u.originFileObj },
context: { useEffect(() => {
jobid: values.jobid, if (billEnterModal.open) {
invoice_number: remainingValues.invoice_number, form.setFieldsValue(formValues);
vendorid: remainingValues.vendorid, } else {
}, form.resetFields();
}); }
}); }, [billEnterModal.open, form, formValues]);
} else {
upload.forEach((u) => { return (
handleUpload( <Modal
{ file: u.originFileObj }, title={t("bills.labels.new")}
{ width={"98%"}
bodyshop: bodyshop, open={billEnterModal.open}
uploaded_by: currentUser.email, okText={t("general.actions.save")}
jobId: values.jobid, keyboard="false"
billId: billId, onOk={() => form.submit()}
tagsArray: null, onCancel={handleCancel}
callback: null, afterClose={() => {
form.resetFields();
setLoading(false);
}}
footer={
<Space>
<Checkbox
checked={generateLabel}
onChange={(e) => setGenerateLabel(e.target.checked)}
>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
>
{t("general.actions.saveandnew")}
</Button>
)}
</Space>
} }
); destroyOnClose
}); >
} <Form
} onFinish={handleFinish}
/////////////////////////// autoComplete={"off"}
setLoading(false); layout="vertical"
notification["success"]({ form={form}
message: t("bills.successes.created"), onFinishFailed={() => {
}); setEnterAgain(false);
}}
if (generateLabel) {
GenerateDocument(
{
name: Templates.parts_invoice_label_single.key,
variables: {
id: billId,
},
},
{},
"p"
);
}
if (billEnterModal.actions.refetch) billEnterModal.actions.refetch();
insertAuditTrail({
jobid: values.jobid,
billid: billId,
operation: AuditTrailMapping.billposted(
r1.data.insert_bills.returning[0].invoice_number
),
});
if (enterAgain) {
// form.resetFields();
form.setFieldsValue({
...formValues,
billlines: [],
});
form.resetFields();
} else {
toggleModalVisible();
}
setEnterAgain(false);
};
const handleCancel = () => {
const r = window.confirm(t("general.labels.cancel"));
if (r === true) {
toggleModalVisible();
}
};
useEffect(() => {
if (enterAgain) form.submit();
}, [enterAgain, form]);
useEffect(() => {
if (billEnterModal.visible) {
form.setFieldsValue(formValues);
} else {
form.resetFields();
}
}, [billEnterModal.visible, form, formValues]);
return (
<Modal
title={t("bills.labels.new")}
width={"98%"}
visible={billEnterModal.visible}
okText={t("general.actions.save")}
keyboard="false"
onOk={() => form.submit()}
onCancel={handleCancel}
afterClose={() => {
form.resetFields();
setLoading(false);
}}
footer={
<Space>
<Checkbox
checked={generateLabel}
onChange={(e) => setGenerateLabel(e.target.checked)}
>
{t("bills.labels.generatepartslabel")}
</Checkbox>
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
<Button loading={loading} onClick={() => form.submit()}>
{t("general.actions.save")}
</Button>
{billEnterModal.context && billEnterModal.context.id ? null : (
<Button
type="primary"
loading={loading}
onClick={() => {
setEnterAgain(true);
}}
> >
{t("general.actions.saveandnew")} <BillFormContainer
</Button> form={form}
)} disableInvNumber={billEnterModal.context.disableInvNumber}
</Space> />
} </Form>
destroyOnClose </Modal>
> );
<Form
onFinish={handleFinish}
autoComplete={"off"}
layout="vertical"
form={form}
onFinishFailed={() => {
setEnterAgain(false);
}}
>
<BillFormContainer
form={form}
disableInvNumber={billEnterModal.context.disableInvNumber}
/>
</Form>
</Modal>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillEnterModalContainer); )(BillEnterModalContainer);

View File

@@ -1,135 +1,136 @@
import { Form, Input, Table } from "antd"; import {Form, Input, Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { alphaSort } from "../../utils/sorters"; import {alphaSort} from "../../utils/sorters";
import BillFormItemsExtendedFormItem from "./bill-form-lines.extended.formitem.component"; import BillFormItemsExtendedFormItem from "./bill-form-lines.extended.formitem.component";
export default function BillFormLinesExtended({ export default function BillFormLinesExtended({
lineData, lineData,
discount, discount,
form, form,
responsibilityCenters, responsibilityCenters,
disabled, disabled,
}) { }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { t } = useTranslation(); const {t} = useTranslation();
const columns = [ const columns = [
{
title: t("joblines.fields.line_desc"),
dataIndex: "line_desc",
key: "line_desc",
width: "10%",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
},
{
title: t("joblines.fields.oem_partno"),
dataIndex: "oem_partno",
key: "oem_partno",
width: "10%",
sorter: (a, b) => alphaSort(a.oem_partno, b.oem_partno),
},
{
title: t("joblines.fields.part_type"),
dataIndex: "part_type",
key: "part_type",
width: "10%",
filters: [
{ {
text: t("jobs.labels.partsfilter"), title: t("joblines.fields.line_desc"),
value: ["PAN", "PAP", "PAL", "PAA", "PAS", "PASL"], dataIndex: "line_desc",
key: "line_desc",
width: "10%",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
}, },
{ {
text: t("joblines.fields.part_types.PAN"), title: t("joblines.fields.oem_partno"),
value: ["PAN", "PAP"], dataIndex: "oem_partno",
key: "oem_partno",
width: "10%",
sorter: (a, b) => alphaSort(a.oem_partno, b.oem_partno),
}, },
{ {
text: t("joblines.fields.part_types.PAL"), title: t("joblines.fields.part_type"),
value: ["PAL"], dataIndex: "part_type",
key: "part_type",
width: "10%",
filters: [
{
text: t("jobs.labels.partsfilter"),
value: ["PAN", "PAP", "PAL", "PAA", "PAS", "PASL"],
},
{
text: t("joblines.fields.part_types.PAN"),
value: ["PAN", "PAP"],
},
{
text: t("joblines.fields.part_types.PAL"),
value: ["PAL"],
},
{
text: t("joblines.fields.part_types.PAA"),
value: ["PAA"],
},
{
text: t("joblines.fields.part_types.PAS"),
value: ["PAS", "PASL"],
},
],
onFilter: (value, record) => value.includes(record.part_type),
render: (text, record) =>
record.part_type
? t(`joblines.fields.part_types.${record.part_type}`)
: null,
}, },
{
text: t("joblines.fields.part_types.PAA"),
value: ["PAA"],
},
{
text: t("joblines.fields.part_types.PAS"),
value: ["PAS", "PASL"],
},
],
onFilter: (value, record) => value.includes(record.part_type),
render: (text, record) =>
record.part_type
? t(`joblines.fields.part_types.${record.part_type}`)
: null,
},
{ {
title: t("joblines.fields.act_price"), title: t("joblines.fields.act_price"),
dataIndex: "act_price", dataIndex: "act_price",
key: "act_price", key: "act_price",
width: "10%", width: "10%",
sorter: (a, b) => a.act_price - b.act_price, sorter: (a, b) => a.act_price - b.act_price,
shouldCellUpdate: false, shouldCellUpdate: false,
render: (text, record) => ( render: (text, record) => (
<> <>
<CurrencyFormatter> <CurrencyFormatter>
{record.db_ref === "900510" || record.db_ref === "900511" {record.db_ref === "900510" || record.db_ref === "900511"
? record.prt_dsmk_m ? record.prt_dsmk_m
: record.act_price} : record.act_price}
</CurrencyFormatter> </CurrencyFormatter>
{record.part_qty ? `(x ${record.part_qty})` : null} {record.part_qty ? `(x ${record.part_qty})` : null}
{record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? ( {record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? (
<span <span
style={{ marginLeft: ".2rem" }} style={{marginLeft: ".2rem"}}
>{`(${record.prt_dsmk_p}%)`}</span> >{`(${record.prt_dsmk_p}%)`}</span>
) : ( ) : (
<></> <></>
)} )}
</> </>
), ),
}, },
{ {
title: t("billlines.fields.posting"), title: t("billlines.fields.posting"),
dataIndex: "posting", dataIndex: "posting",
key: "posting", key: "posting",
render: (text, record, index) => ( render: (text, record, index) => (
<Form.Item noStyle name={["billlineskeys", record.id]}> <Form.Item noStyle name={["billlineskeys", record.id]}>
<BillFormItemsExtendedFormItem <BillFormItemsExtendedFormItem
form={form} form={form}
record={record} record={record}
index={index} index={index}
responsibilityCenters={responsibilityCenters} responsibilityCenters={responsibilityCenters}
discount={discount} discount={discount}
/> />
</Form.Item>
),
},
];
const data =
search === ""
? lineData
: lineData.filter(
(l) =>
(l.line_desc &&
l.line_desc.toLowerCase().includes(search.toLowerCase())) ||
(l.oem_partno &&
l.oem_partno.toLowerCase().includes(search.toLowerCase())) ||
(l.act_price &&
l.act_price.toString().startsWith(search.toString()))
);
return (
<Form.Item noStyle name="billlineskeys">
<button onClick={() => console.log(form.getFieldsValue())}>form</button>
<Input onChange={(e) => setSearch(e.target.value)} allowClear/>
<Table
pagination={false}
size="small"
columns={columns}
rowKey="id"
dataSource={data}
/>
</Form.Item> </Form.Item>
), );
},
];
const data =
search === ""
? lineData
: lineData.filter(
(l) =>
(l.line_desc &&
l.line_desc.toLowerCase().includes(search.toLowerCase())) ||
(l.oem_partno &&
l.oem_partno.toLowerCase().includes(search.toLowerCase())) ||
(l.act_price &&
l.act_price.toString().startsWith(search.toString()))
);
return (
<Form.Item noStyle name="billlineskeys">
<button onClick={() => console.log(form.getFieldsValue())}>form</button>
<Input onChange={(e) => setSearch(e.target.value)} allowClear />
<Table
pagination={false}
size="small"
columns={columns}
rowKey="id"
dataSource={data}
/>
</Form.Item>
);
} }

View File

@@ -1,288 +1,284 @@
import React from "react"; import React from "react";
import { import {MinusCircleFilled, PlusCircleFilled, WarningOutlined,} from "@ant-design/icons";
PlusCircleFilled, import {Button, Form, Input, InputNumber, Select, Space, Switch} from "antd";
MinusCircleFilled, import {useTranslation} from "react-i18next";
WarningOutlined,
} from "@ant-design/icons";
import { Form, Button, InputNumber, Input, Select, Switch, Space } from "antd";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import CurrencyInput from "../form-items-formatted/currency-form-item.component"; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import CiecaSelect from "../../utils/Ciecaselect"; import CiecaSelect from "../../utils/Ciecaselect";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillFormItemsExtendedFormItem); )(BillFormItemsExtendedFormItem);
export function BillFormItemsExtendedFormItem({ export function BillFormItemsExtendedFormItem({
value, value,
bodyshop, bodyshop,
form, form,
record, record,
index, index,
disabled, disabled,
responsibilityCenters, responsibilityCenters,
discount, discount,
}) { }) {
// const { billlineskeys } = form.getFieldsValue("billlineskeys"); // const { billlineskeys } = form.getFieldsValue("billlineskeys");
const {t} = useTranslation();
if (!value)
return (
<Button
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
form.setFieldsValue({
...values,
billlineskeys: {
...(values.billlineskeys || {}),
[record.id]: {
joblineid: record.id,
line_desc: record.line_desc,
quantity: record.part_qty || 1,
actual_price: record.act_price,
cost_center: record.part_type
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid
? record.part_type
: responsibilityCenters.defaults &&
(responsibilityCenters.defaults.costs[record.part_type] ||
null)
: null,
},
},
});
}}
>
<PlusCircleFilled/>
</Button>
);
const { t } = useTranslation();
if (!value)
return ( return (
<Button <Space wrap>
onClick={() => { <Form.Item
const values = form.getFieldsValue("billlineskeys"); label={t("billlines.fields.line_desc")}
name={["billlineskeys", record.id, "line_desc"]}
>
<Input disabled={disabled}/>
</Form.Item>
<Form.Item
label={t("billlines.fields.quantity")}
name={["billlineskeys", record.id, "quantity"]}
>
<InputNumber precision={0} min={0} disabled={disabled}/>
</Form.Item>
<Form.Item
label={t("billlines.fields.actual_price")}
name={["billlineskeys", record.id, "actual_price"]}
>
<CurrencyInput
min={0}
disabled={disabled}
onBlur={(e) => {
const {billlineskeys} = form.getFieldsValue("billlineskeys");
form.setFieldsValue({
billlineskeys: {
...billlineskeys,
[record.id]: {
...billlineskeys[billlineskeys],
actual_cost: !!billlineskeys[billlineskeys].actual_cost
? billlineskeys[billlineskeys].actual_cost
: Math.round(
(parseFloat(e.target.value) * (1 - discount) +
Number.EPSILON) *
100
) / 100,
},
},
});
}}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.actual_cost")}
name={["billlineskeys", record.id, "actual_cost"]}
>
<CurrencyInput min={0} disabled={disabled}/>
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const line = value;
if (!!!line) return null;
const lineDiscount = (
1 -
Math.round((line.actual_cost / line.actual_price) * 100) / 100
).toPrecision(2);
form.setFieldsValue({ if (lineDiscount - discount === 0) return <div/>;
...values, return <WarningOutlined style={{color: "red"}}/>;
billlineskeys: { }}
...(values.billlineskeys || {}), </Form.Item>
[record.id]: { <Form.Item
joblineid: record.id, label={t("billlines.fields.cost_center")}
line_desc: record.line_desc, name={["billlineskeys", record.id, "cost_center"]}
quantity: record.part_qty || 1, >
actual_price: record.act_price, <Select showSearch style={{minWidth: "3rem"}} disabled={disabled}>
cost_center: record.part_type {bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? CiecaSelect(true, false)
? record.part_type : responsibilityCenters.costs.map((item) => (
: responsibilityCenters.defaults && <Select.Option key={item.name}>{item.name}</Select.Option>
(responsibilityCenters.defaults.costs[record.part_type] || ))}
null) </Select>
: null, </Form.Item>
}, <Form.Item
}, label={t("billlines.fields.location")}
}); name={["billlineskeys", record.id, "location"]}
}} >
> <Select disabled={disabled}>
<PlusCircleFilled /> {bodyshop.md_parts_locations.map((loc, idx) => (
</Button> <Select.Option key={idx} value={loc}>
{loc}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label={t("billlines.fields.deductedfromlbr")}
name={["billlineskeys", record.id, "deductedfromlbr"]}
valuePropName="checked"
>
<Switch disabled={disabled}/>
</Form.Item>
<Form.Item shouldUpdate style={{display: "inline-block"}}>
{() => {
if (
form.getFieldsValue("billlineskeys").billlineskeys[record.id]
.deductedfromlbr
)
return (
<div>
<Form.Item
label={t("joblines.fields.mod_lbr_ty")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
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>
</Form.Item>
<Form.Item
label={t("jobs.labels.adjustmentrate")}
name={["billlineskeys", record.id, "lbr_adjustment", "rate"]}
initialValue={bodyshop.default_adjustment_rate}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber precision={2} min={0.01}/>
</Form.Item>
</div>
);
return <></>;
}}
</Form.Item>
<Form.Item
label={t("billlines.fields.federal_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "federal"]}
valuePropName="checked"
>
<Switch disabled={disabled}/>
</Form.Item>
<Form.Item
label={t("billlines.fields.state_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "state"]}
valuePropName="checked"
>
<Switch disabled={disabled}/>
</Form.Item>
<Form.Item
label={t("billlines.fields.local_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "local"]}
valuePropName="checked"
>
<Switch disabled={disabled}/>
</Form.Item>
<Button
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
form.setFieldsValue({
...values,
billlineskeys: {
...(values.billlineskeys || {}),
[record.id]: null,
},
});
}}
>
<MinusCircleFilled/>
</Button>
</Space>
); );
return (
<Space wrap>
<Form.Item
label={t("billlines.fields.line_desc")}
name={["billlineskeys", record.id, "line_desc"]}
>
<Input disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.quantity")}
name={["billlineskeys", record.id, "quantity"]}
>
<InputNumber precision={0} min={0} disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.actual_price")}
name={["billlineskeys", record.id, "actual_price"]}
>
<CurrencyInput
min={0}
disabled={disabled}
onBlur={(e) => {
const { billlineskeys } = form.getFieldsValue("billlineskeys");
form.setFieldsValue({
billlineskeys: {
...billlineskeys,
[record.id]: {
...billlineskeys[billlineskeys],
actual_cost: !!billlineskeys[billlineskeys].actual_cost
? billlineskeys[billlineskeys].actual_cost
: Math.round(
(parseFloat(e.target.value) * (1 - discount) +
Number.EPSILON) *
100
) / 100,
},
},
});
}}
/>
</Form.Item>
<Form.Item
label={t("billlines.fields.actual_cost")}
name={["billlineskeys", record.id, "actual_cost"]}
>
<CurrencyInput min={0} disabled={disabled} />
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const line = value;
if (!!!line) return null;
const lineDiscount = (
1 -
Math.round((line.actual_cost / line.actual_price) * 100) / 100
).toPrecision(2);
if (lineDiscount - discount === 0) return <div />;
return <WarningOutlined style={{ color: "red" }} />;
}}
</Form.Item>
<Form.Item
label={t("billlines.fields.cost_center")}
name={["billlineskeys", record.id, "cost_center"]}
>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => (
<Select.Option key={item.name}>{item.name}</Select.Option>
))}
</Select>
</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>
</Form.Item>
<Form.Item
label={t("billlines.fields.deductedfromlbr")}
name={["billlineskeys", record.id, "deductedfromlbr"]}
valuePropName="checked"
>
<Switch disabled={disabled} />
</Form.Item>
<Form.Item shouldUpdate style={{ display: "inline-block" }}>
{() => {
if (
form.getFieldsValue("billlineskeys").billlineskeys[record.id]
.deductedfromlbr
)
return (
<div>
<Form.Item
label={t("joblines.fields.mod_lbr_ty")}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
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>
</Form.Item>
<Form.Item
label={t("jobs.labels.adjustmentrate")}
name={["billlineskeys", record.id, "lbr_adjustment", "rate"]}
initialValue={bodyshop.default_adjustment_rate}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<InputNumber precision={2} min={0.01} />
</Form.Item>
</div>
);
return <></>;
}}
</Form.Item>
<Form.Item
label={t("billlines.fields.federal_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "federal"]}
valuePropName="checked"
>
<Switch disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.state_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "state"]}
valuePropName="checked"
>
<Switch disabled={disabled} />
</Form.Item>
<Form.Item
label={t("billlines.fields.local_tax_applicable")}
name={["billlineskeys", record.id, "applicable_taxes", "local"]}
valuePropName="checked"
>
<Switch disabled={disabled} />
</Form.Item>
<Button
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
form.setFieldsValue({
...values,
billlineskeys: {
...(values.billlineskeys || {}),
[record.id]: null,
},
});
}}
>
<MinusCircleFilled />
</Button>
</Space>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1,83 @@
import { useLazyQuery, useQuery } from "@apollo/client"; import {useLazyQuery, useQuery} from "@apollo/client";
import { useTreatments } from "@splitsoftware/splitio-react"; import {useSplitTreatments} from "@splitsoftware/splitio-react";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { QUERY_OUTSTANDING_INVENTORY } from "../../graphql/inventory.queries"; import {QUERY_OUTSTANDING_INVENTORY} from "../../graphql/inventory.queries";
import { GET_JOB_LINES_TO_ENTER_BILL } from "../../graphql/jobs-lines.queries"; import {GET_JOB_LINES_TO_ENTER_BILL} from "../../graphql/jobs-lines.queries";
import { QUERY_UNRECEIVED_LINES } from "../../graphql/parts-orders.queries"; import {QUERY_UNRECEIVED_LINES} from "../../graphql/parts-orders.queries";
import { SEARCH_VENDOR_AUTOCOMPLETE } from "../../graphql/vendors.queries"; import {SEARCH_VENDOR_AUTOCOMPLETE} from "../../graphql/vendors.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import BillCmdReturnsTableComponent from "../bill-cm-returns-table/bill-cm-returns-table.component"; import BillCmdReturnsTableComponent from "../bill-cm-returns-table/bill-cm-returns-table.component";
import BillInventoryTable from "../bill-inventory-table/bill-inventory-table.component"; import BillInventoryTable from "../bill-inventory-table/bill-inventory-table.component";
import BillFormComponent from "./bill-form.component"; import BillFormComponent from "./bill-form.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
export function BillFormContainer({ export function BillFormContainer({
bodyshop, bodyshop,
form, form,
billEdit, billEdit,
disabled, disabled,
disableInvNumber, disableInvNumber,
}) { }) {
const { Simple_Inventory } = useTreatments( const {treatments: {Simple_Inventory}} = useSplitTreatments({
["Simple_Inventory"], attributes: {},
{}, names: ["Simple_Inventory"],
bodyshop && bodyshop.imexshopid splitKey: bodyshop && bodyshop.imexshopid,
); });
const { data: VendorAutoCompleteData } = useQuery( const {data: VendorAutoCompleteData} = useQuery(
SEARCH_VENDOR_AUTOCOMPLETE, SEARCH_VENDOR_AUTOCOMPLETE,
{ fetchPolicy: "network-only", nextFetchPolicy: "network-only" } {fetchPolicy: "network-only", nextFetchPolicy: "network-only"}
); );
const [loadLines, { data: lineData }] = useLazyQuery( const [loadLines, {data: lineData}] = useLazyQuery(
GET_JOB_LINES_TO_ENTER_BILL GET_JOB_LINES_TO_ENTER_BILL
); );
const [loadOutstandingReturns, { loading: returnLoading, data: returnData }] = const [loadOutstandingReturns, {loading: returnLoading, data: returnData}] =
useLazyQuery(QUERY_UNRECEIVED_LINES); useLazyQuery(QUERY_UNRECEIVED_LINES);
const [loadInventory, { loading: inventoryLoading, data: inventoryData }] = const [loadInventory, {loading: inventoryLoading, data: inventoryData}] =
useLazyQuery(QUERY_OUTSTANDING_INVENTORY); useLazyQuery(QUERY_OUTSTANDING_INVENTORY);
return ( return (
<> <>
<BillFormComponent <BillFormComponent
disabled={disabled} disabled={disabled}
form={form} form={form}
billEdit={billEdit} billEdit={billEdit}
vendorAutoCompleteOptions={ vendorAutoCompleteOptions={
VendorAutoCompleteData && VendorAutoCompleteData.vendors VendorAutoCompleteData && VendorAutoCompleteData.vendors
} }
loadLines={loadLines} loadLines={loadLines}
lineData={lineData ? lineData.joblines : []} lineData={lineData ? lineData.joblines : []}
job={lineData ? lineData.jobs_by_pk : null} job={lineData ? lineData.jobs_by_pk : null}
responsibilityCenters={bodyshop.md_responsibility_centers || null} responsibilityCenters={bodyshop.md_responsibility_centers || null}
disableInvNumber={disableInvNumber} disableInvNumber={disableInvNumber}
loadOutstandingReturns={loadOutstandingReturns} loadOutstandingReturns={loadOutstandingReturns}
loadInventory={loadInventory} loadInventory={loadInventory}
preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null} preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null}
/> />
{!billEdit && ( {!billEdit && (
<BillCmdReturnsTableComponent <BillCmdReturnsTableComponent
form={form} form={form}
returnLoading={returnLoading} returnLoading={returnLoading}
returnData={returnData} returnData={returnData}
/> />
)} )}
{Simple_Inventory.treatment === "on" && ( {Simple_Inventory.treatment === "on" && (
<BillInventoryTable <BillInventoryTable
form={form} form={form}
inventoryLoading={inventoryLoading} inventoryLoading={inventoryLoading}
inventoryData={billEdit ? [] : inventoryData} inventoryData={billEdit ? [] : inventoryData}
billEdit={billEdit} billEdit={billEdit}
/> />
)} )}
</> </>
); );
} }
export default connect(mapStateToProps, null)(BillFormContainer); export default connect(mapStateToProps, null)(BillFormContainer);

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,47 @@
import Dinero from "dinero.js"; import Dinero from "dinero.js";
export const CalculateBillTotal = (invoice) => { export const CalculateBillTotal = (invoice) => {
const { total, billlines, federal_tax_rate, local_tax_rate, state_tax_rate } = const {total, billlines, federal_tax_rate, local_tax_rate, state_tax_rate} =
invoice; invoice;
//TODO Determine why this recalculates so many times. //TODO Determine why this recalculates so many times.
let subtotal = Dinero({ amount: 0 }); let subtotal = Dinero({amount: 0});
let federalTax = Dinero({ amount: 0 }); let federalTax = Dinero({amount: 0});
let stateTax = Dinero({ amount: 0 }); let stateTax = Dinero({amount: 0});
let localTax = Dinero({ amount: 0 }); let localTax = Dinero({amount: 0});
if (!!!billlines) return null; if (!!!billlines) return null;
billlines.forEach((i) => { billlines.forEach((i) => {
if (!!i) { if (!!i) {
const itemTotal = Dinero({ const itemTotal = Dinero({
amount: Math.round((i.actual_cost || 0) * 100), amount: Math.round((i.actual_cost || 0) * 100),
}).multiply(i.quantity || 1); }).multiply(i.quantity || 1);
subtotal = subtotal.add(itemTotal); subtotal = subtotal.add(itemTotal);
if (i.applicable_taxes?.federal) { if (i.applicable_taxes?.federal) {
federalTax = federalTax.add( federalTax = federalTax.add(
itemTotal.percentage(federal_tax_rate || 0) itemTotal.percentage(federal_tax_rate || 0)
); );
} }
if (i.applicable_taxes?.state) if (i.applicable_taxes?.state)
stateTax = stateTax.add(itemTotal.percentage(state_tax_rate || 0)); stateTax = stateTax.add(itemTotal.percentage(state_tax_rate || 0));
if (i.applicable_taxes?.local) if (i.applicable_taxes?.local)
localTax = localTax.add(itemTotal.percentage(local_tax_rate || 0)); localTax = localTax.add(itemTotal.percentage(local_tax_rate || 0));
} }
}); });
const invoiceTotal = Dinero({ amount: Math.round((total || 0) * 100) }); const invoiceTotal = Dinero({amount: Math.round((total || 0) * 100)});
const enteredTotal = subtotal.add(federalTax).add(stateTax).add(localTax); const enteredTotal = subtotal.add(federalTax).add(stateTax).add(localTax);
const discrepancy = enteredTotal.subtract(invoiceTotal); const discrepancy = enteredTotal.subtract(invoiceTotal);
return { return {
subtotal, subtotal,
federalTax, federalTax,
stateTax, stateTax,
localTax, localTax,
enteredTotal, enteredTotal,
invoiceTotal, invoiceTotal,
discrepancy, discrepancy,
}; };
}; };

View File

@@ -1,173 +1,173 @@
import { Checkbox, Form, Skeleton, Typography } from "antd"; import {Checkbox, Form, Skeleton, Typography} from "antd";
import React, { useEffect } from "react"; import React, {useEffect} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component"; import ReadOnlyFormItemComponent from "../form-items-formatted/read-only-form-item.component";
import "./bill-inventory-table.styles.scss"; import "./bill-inventory-table.styles.scss";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import { selectBillEnterModal } from "../../redux/modals/modals.selectors"; import {selectBillEnterModal} from "../../redux/modals/modals.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
billEnterModal: selectBillEnterModal, billEnterModal: selectBillEnterModal,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(BillInventoryTable); export default connect(mapStateToProps, mapDispatchToProps)(BillInventoryTable);
export function BillInventoryTable({ export function BillInventoryTable({
billEnterModal, billEnterModal,
bodyshop, bodyshop,
form, form,
billEdit, billEdit,
inventoryLoading, inventoryLoading,
inventoryData, inventoryData,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
useEffect(() => { useEffect(() => {
if (inventoryData && inventoryData.inventory) { if (inventoryData && inventoryData.inventory) {
form.setFieldsValue({ form.setFieldsValue({
inventory: billEnterModal.context.consumeinventoryid inventory: billEnterModal.context.consumeinventoryid
? inventoryData.inventory.map((i) => { ? inventoryData.inventory.map((i) => {
if (i.id === billEnterModal.context.consumeinventoryid) if (i.id === billEnterModal.context.consumeinventoryid)
i.consumefrominventory = true; i.consumefrominventory = true;
return i; return i;
}) })
: inventoryData.inventory, : inventoryData.inventory,
}); });
}
}, [inventoryData, form, billEnterModal.context.consumeinventoryid]);
return (
<Form.Item
shouldUpdate={(prev, cur) => prev.vendorid !== cur.vendorid}
noStyle
>
{() => {
const is_inhouse =
form.getFieldValue("vendorid") === bodyshop.inhousevendorid;
if (!is_inhouse || billEdit) {
return null;
} }
}, [inventoryData, form, billEnterModal.context.consumeinventoryid]);
if (inventoryLoading) return <Skeleton />; return (
<Form.Item
shouldUpdate={(prev, cur) => prev.vendorid !== cur.vendorid}
noStyle
>
{() => {
const is_inhouse =
form.getFieldValue("vendorid") === bodyshop.inhousevendorid;
return ( if (!is_inhouse || billEdit) {
<Form.List name="inventory"> return null;
{(fields, { add, remove, move }) => { }
return (
<>
<Typography.Title level={4}>
{t("inventory.labels.inventory")}
</Typography.Title>
<table className="bill-inventory-table">
<thead>
<tr>
<th>{t("billlines.fields.line_desc")}</th>
<th>{t("vendors.fields.name")}</th>
<th>{t("billlines.fields.quantity")}</th>
<th>{t("billlines.fields.actual_price")}</th>
<th>{t("billlines.fields.actual_cost")}</th>
<th>{t("inventory.fields.comment")}</th>
<th>{t("inventory.actions.consumefrominventory")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td> if (inventoryLoading) return <Skeleton/>;
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}part_type`}
name={[
field.name,
"billline",
"bill",
"vendor",
"name",
]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}act_price`}
name={[field.name, "actual_price"]}
>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}cost`}
name={[field.name, "actual_cost"]}
>
<ReadOnlyFormItemComponent type="currency" />
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}comment`}
name={[field.name, "comment"]}
>
<ReadOnlyFormItemComponent />
</Form.Item>
</td>
<td> return (
<Form.Item <Form.List name="inventory">
span={2} {(fields, {add, remove, move}) => {
//label={t("joblines.fields.mod_lb_hrs")} return (
key={`${index}consumefrominventory`} <>
name={[field.name, "consumefrominventory"]} <Typography.Title level={4}>
valuePropName="checked" {t("inventory.labels.inventory")}
> </Typography.Title>
<Checkbox /> <table className="bill-inventory-table">
</Form.Item> <thead>
</td> <tr>
</tr> <th>{t("billlines.fields.line_desc")}</th>
))} <th>{t("vendors.fields.name")}</th>
</tbody> <th>{t("billlines.fields.quantity")}</th>
</table> <th>{t("billlines.fields.actual_price")}</th>
</> <th>{t("billlines.fields.actual_cost")}</th>
); <th>{t("inventory.fields.comment")}</th>
<th>{t("inventory.actions.consumefrominventory")}</th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}
name={[field.name, "line_desc"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}part_type`}
name={[
field.name,
"billline",
"bill",
"vendor",
"name",
]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}quantity`}
name={[field.name, "quantity"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}act_price`}
name={[field.name, "actual_price"]}
>
<ReadOnlyFormItemComponent type="currency"/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}cost`}
name={[field.name, "actual_cost"]}
>
<ReadOnlyFormItemComponent type="currency"/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}comment`}
name={[field.name, "comment"]}
>
<ReadOnlyFormItemComponent/>
</Form.Item>
</td>
<td>
<Form.Item
span={2}
//label={t("joblines.fields.mod_lb_hrs")}
key={`${index}consumefrominventory`}
name={[field.name, "consumefrominventory"]}
valuePropName="checked"
>
<Checkbox/>
</Form.Item>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}}
</Form.List>
);
}} }}
</Form.List> </Form.Item>
); );
}}
</Form.Item>
);
} }

View File

@@ -1,19 +1,19 @@
.bill-inventory-table { .bill-inventory-table {
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
th, th,
td { td {
padding: 8px; padding: 8px;
text-align: left; text-align: left;
border-bottom: 1px solid #ddd; border-bottom: 1px solid #ddd;
.ant-form-item { .ant-form-item {
margin-bottom: 0px !important; margin-bottom: 0px !important;
}
} }
}
tr:hover { tr:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
} }

View File

@@ -1,83 +1,87 @@
import { Select } from "antd"; import {Select} from "antd";
import React, { forwardRef } from "react"; import React, {forwardRef} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
//To be used as a form element only. //To be used as a form element only.
const { Option } = Select; const {Option} = Select;
const BillLineSearchSelect = ( const BillLineSearchSelect = (
{ options, disabled, allowRemoved, ...restProps }, {options, disabled, allowRemoved, ...restProps},
ref ref
) => { ) => {
const { t } = useTranslation(); const {t} = useTranslation();
return ( return (
<Select <Select
disabled={disabled} disabled={disabled}
ref={ref} ref={ref}
showSearch showSearch
dropdownMatchSelectWidth={false} popupMatchSelectWidth={false}
// optionFilterProp="line_desc" optionLabelProp={"name"}
filterOption={(inputValue, option) => { // optionFilterProp="line_desc"
return ( filterOption={(inputValue, option) => {
(option.line_desc && return (
option.line_desc (option.line_desc &&
.toLowerCase() option.line_desc
.includes(inputValue.toLowerCase())) || .toLowerCase()
(option.oem_partno && .includes(inputValue.toLowerCase())) ||
option.oem_partno (option.oem_partno &&
.toLowerCase() option.oem_partno
.includes(inputValue.toLowerCase())) || .toLowerCase()
(option.alt_partno && .includes(inputValue.toLowerCase())) ||
option.alt_partno (option.alt_partno &&
.toLowerCase() option.alt_partno
.includes(inputValue.toLowerCase())) || .toLowerCase()
(option.act_price && .includes(inputValue.toLowerCase())) ||
option.act_price.toString().startsWith(inputValue.toString())) (option.act_price &&
); option.act_price.toString().startsWith(inputValue.toString()))
}} );
notFoundContent={"Removed."} }}
{...restProps} notFoundContent={"Removed."}
> {...restProps}
<Select.Option key={null} value={"noline"} cost={0} line_desc={""}> >
{t("billlines.labels.other")} <Select.Option key={null} value={"noline"} cost={0} line_desc={""}>
</Select.Option> {t("billlines.labels.other")}
{options </Select.Option>
? options.map((item) => ( {options
<Option ? options.map((item) => (
disabled={allowRemoved ? false : item.removed} <Option
key={item.id} disabled={allowRemoved ? false : item.removed}
value={item.id} key={item.id}
cost={item.act_price ? item.act_price : 0} value={item.id}
part_type={item.part_type} cost={item.act_price ? item.act_price : 0}
line_desc={item.line_desc} part_type={item.part_type}
part_qty={item.part_qty} line_desc={item.line_desc}
oem_partno={item.oem_partno} part_qty={item.part_qty}
alt_partno={item.alt_partno} oem_partno={item.oem_partno}
act_price={item.act_price} alt_partno={item.alt_partno}
style={{ act_price={item.act_price}
...(item.removed ? { textDecoration: "line-through" } : {}), style={{
}} ...(item.removed ? {textDecoration: "line-through"} : {}),
> }}
name={`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
>
<span> <span>
{`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${ {`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
item.oem_partno ? ` - ${item.oem_partno}` : "" item.oem_partno ? ` - ${item.oem_partno}` : ""
}${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()} }${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
</span> </span>
{item.act_price === 0 && item.mod_lb_hrs > 0 && ( {item.act_price === 0 && item.mod_lb_hrs > 0 && (
<span style={{ float: "right", paddingleft: "1rem" }}> <span style={{float: "right", paddingleft: "1rem"}}>
{`${item.mod_lb_hrs} units`} {`${item.mod_lb_hrs} units`}
</span> </span>
)} )}
<span style={{ float: "right", paddingleft: "1rem" }}> <span style={{float: "right", paddingleft: "1rem"}}>
{item.act_price {item.act_price
? `$${item.act_price && item.act_price.toFixed(2)}` ? `$${item.act_price && item.act_price.toFixed(2)}`
: ``} : ``}
</span> </span>
</Option> </Option>
)) ))
: null} : null}
</Select> </Select>
); );
}; };
export default forwardRef(BillLineSearchSelect); export default forwardRef(BillLineSearchSelect);

View File

@@ -1,101 +1,97 @@
import { useMutation } from "@apollo/client"; import {gql, useMutation} from "@apollo/client";
import { Button, notification } from "antd"; import {Button, notification} from "antd";
import { gql } from "@apollo/client"; import React, {useState} from "react";
import React, { useState } from "react"; import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import {connect} from "react-redux";
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";
import { connect } from "react-redux";
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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
authLevel: selectAuthLevel, authLevel: selectAuthLevel,
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillMarkExportedButton); )(BillMarkExportedButton);
export function BillMarkExportedButton({ export function BillMarkExportedButton({
currentUser, currentUser,
bodyshop, bodyshop,
authLevel, authLevel,
bill, bill,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [insertExportLog] = useMutation(INSERT_EXPORT_LOG); const [insertExportLog] = useMutation(INSERT_EXPORT_LOG);
const [updateBill] = useMutation(gql` const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) { mutation UPDATE_BILL($billId: uuid!) {
update_bills(where: { id: { _eq: $billId } }, _set: { exported: true }) { update_bills(where: { id: { _eq: $billId } }, _set: { exported: true }) {
returning { returning {
id id
exported exported
exported_at exported_at
}
}
} }
} `);
}
`);
const handleUpdate = async () => { const handleUpdate = async () => {
setLoading(true); setLoading(true);
const result = await updateBill({ const result = await updateBill({
variables: { billId: bill.id }, 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"),
});
} else {
notification["error"]({
message: t("bills.errors.saving", {
error: JSON.stringify(result.errors),
}),
});
}
setLoading(false);
//Get the owner details, populate it all back into the job.
};
const hasAccess = HasRbacAccess({
bodyshop,
authLevel,
action: "bills:reexport",
}); });
await insertExportLog({ if (hasAccess)
variables: { return (
logs: [ <Button loading={loading} disabled={bill.exported} onClick={handleUpdate}>
{ {t("bills.labels.markexported")}
bodyshopid: bodyshop.id, </Button>
billid: bill.id, );
successful: true,
message: JSON.stringify([t("general.labels.markedexported")]),
useremail: currentUser.email,
},
],
},
});
if (!result.errors) { return <></>;
notification["success"]({
message: t("bills.successes.markexported"),
});
} else {
notification["error"]({
message: t("bills.errors.saving", {
error: JSON.stringify(result.errors),
}),
});
}
setLoading(false);
//Get the owner details, populate it all back into the job.
};
const hasAccess = HasRbacAccess({
bodyshop,
authLevel,
action: "bills:reexport",
});
if (hasAccess)
return (
<Button loading={loading} disabled={bill.exported} onClick={handleUpdate}>
{t("bills.labels.markexported")}
</Button>
);
return <></>;
} }

View File

@@ -1,38 +1,38 @@
import { Button, Space } from "antd"; import {Button, Space} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { GenerateDocument } from "../../utils/RenderTemplate"; import {GenerateDocument} from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import {TemplateList} from "../../utils/TemplateConstants";
export default function BillPrintButton({ billid }) { export default function BillPrintButton({billid}) {
const { t } = useTranslation(); const {t} = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const Templates = TemplateList("job_special"); const Templates = TemplateList("job_special");
const submitHandler = async () => { const submitHandler = async () => {
setLoading(true); setLoading(true);
try { try {
await GenerateDocument( await GenerateDocument(
{ {
name: Templates.parts_invoice_label_single.key, name: Templates.parts_invoice_label_single.key,
variables: { variables: {
id: billid, id: billid,
}, },
}, },
{}, {},
"p" "p"
); );
} catch (e) { } catch (e) {
console.warn("Warning: Error generating a document."); console.warn("Warning: Error generating a document.");
} }
setLoading(false); setLoading(false);
}; };
return ( return (
<Space wrap> <Space wrap>
<Button loading={loading} onClick={submitHandler}> <Button loading={loading} onClick={submitHandler}>
{t("bills.labels.printlabels")} {t("bills.labels.printlabels")}
</Button> </Button>
</Space> </Space>
); );
} }

View File

@@ -1,82 +1,79 @@
import { useMutation } from "@apollo/client"; import {gql, useMutation} from "@apollo/client";
import { Button, notification } from "antd"; import {Button, notification} from "antd";
import { gql } from "@apollo/client"; import React, {useState} from "react";
import React, { useState } from "react"; import {useTranslation} from "react-i18next";
import { useTranslation } from "react-i18next";
import {connect} from "react-redux";
import {createStructuredSelector} from "reselect";
import {selectAuthLevel, selectBodyshop,} from "../../redux/user/user.selectors";
import {HasRbacAccess} from "../rbac-wrapper/rbac-wrapper.component";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import {
selectAuthLevel,
selectBodyshop,
} from "../../redux/user/user.selectors";
import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
authLevel: selectAuthLevel, authLevel: selectAuthLevel,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillMarkForReexportButton); )(BillMarkForReexportButton);
export function BillMarkForReexportButton({ bodyshop, authLevel, bill }) { export function BillMarkForReexportButton({bodyshop, authLevel, bill}) {
const { t } = useTranslation(); const {t} = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updateBill] = useMutation(gql` const [updateBill] = useMutation(gql`
mutation UPDATE_BILL($billId: uuid!) { mutation UPDATE_BILL($billId: uuid!) {
update_bills(where: { id: { _eq: $billId } }, _set: { exported: false }) { update_bills(where: { id: { _eq: $billId } }, _set: { exported: false }) {
returning { returning {
id id
exported exported
exported_at exported_at
}
}
} }
} `);
}
`);
const handleUpdate = async () => { const handleUpdate = async () => {
setLoading(true); setLoading(true);
const result = await updateBill({ const result = await updateBill({
variables: { billId: bill.id }, variables: {billId: bill.id},
});
if (!result.errors) {
notification["success"]({
message: t("bills.successes.reexport"),
});
} else {
notification["error"]({
message: t("bills.errors.saving", {
error: JSON.stringify(result.errors),
}),
});
}
setLoading(false);
//Get the owner details, populate it all back into the job.
};
const hasAccess = HasRbacAccess({
bodyshop,
authLevel,
action: "bills:reexport",
}); });
if (!result.errors) { if (hasAccess)
notification["success"]({ return (
message: t("bills.successes.reexport"), <Button
}); loading={loading}
} else { disabled={!bill.exported}
notification["error"]({ onClick={handleUpdate}
message: t("bills.errors.saving", { >
error: JSON.stringify(result.errors), {t("bills.labels.markforreexport")}
}), </Button>
}); );
}
setLoading(false);
//Get the owner details, populate it all back into the job.
};
const hasAccess = HasRbacAccess({ return <></>;
bodyshop,
authLevel,
action: "bills:reexport",
});
if (hasAccess)
return (
<Button
loading={loading}
disabled={!bill.exported}
onClick={handleUpdate}
>
{t("bills.labels.markforreexport")}
</Button>
);
return <></>;
} }

View File

@@ -1,155 +1,151 @@
import { FileAddFilled } from "@ant-design/icons"; import {FileAddFilled} from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { Button, notification, Tooltip } from "antd"; import {Button, notification, Tooltip} from "antd";
import { t } from "i18next"; import {t} from "i18next";
import moment from "moment"; import dayjs from "./../../utils/day";
import React, { useState } from "react"; import React, {useState} from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { INSERT_INVENTORY_AND_CREDIT } from "../../graphql/inventory.queries"; import {INSERT_INVENTORY_AND_CREDIT} from "../../graphql/inventory.queries";
import { import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors";
selectBodyshop, import {CalculateBillTotal} from "../bill-form/bill-form.totals.utility";
selectCurrentUser,
} from "../../redux/user/user.selectors";
import { CalculateBillTotal } from "../bill-form/bill-form.totals.utility";
import queryString from "query-string"; import queryString from "query-string";
import { useLocation } from "react-router-dom"; import {useLocation} from "react-router-dom";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser, currentUser: selectCurrentUser,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BilllineAddInventory); )(BilllineAddInventory);
export function BilllineAddInventory({ export function BilllineAddInventory({
currentUser, currentUser,
bodyshop, bodyshop,
billline, billline,
disabled, disabled,
jobid, jobid,
}) { }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { billid } = queryString.parse(useLocation().search); const {billid} = queryString.parse(useLocation().search);
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT); const addToInventory = async () => {
setLoading(true);
const addToInventory = async () => { //Check to make sure there are no existing items already in the inventory.
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: dayjs().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,
},
},
],
};
const cm = { cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih", const insertResult = await insertInventoryLine({
jobid: jobid, variables: {
isinhouse: true, joblineId:
is_credit_memo: true, 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.
date: moment().format("YYYY-MM-DD"), //Unfortunately, we can't send null as the GQL syntax validation fails.
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate, joblineStatus: bodyshop.md_order_statuses.default_returned,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate, inv: {
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate, shopid: bodyshop.id,
total: 0, billlineid: billline.id,
billlines: [ actual_price: billline.actual_price,
{ actual_cost: billline.actual_cost,
actual_price: billline.actual_price, quantity: billline.quantity,
actual_cost: billline.actual_cost, line_desc: billline.line_desc,
quantity: billline.quantity, },
line_desc: billline.line_desc, cm: {...cm, billlines: {data: cm.billlines}}, //Fix structure for apollo insert.
cost_center: billline.cost_center, pol: {
deductedfromlbr: billline.deductedfromlbr, returnfrombill: billid,
applicable_taxes: { vendorid: bodyshop.inhousevendorid,
local: billline.applicable_taxes.local, deliver_by: dayjs().format("YYYY-MM-DD"),
state: billline.applicable_taxes.state, parts_order_lines: {
federal: billline.applicable_taxes.federal, 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);
}; };
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100; return (
<Tooltip title={t("inventory.actions.addtoinventory")}>
const insertResult = await insertInventoryLine({ <Button
variables: { loading={loading}
joblineId: disabled={
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. disabled || billline?.inventories?.length >= billline.quantity
//Unfortunately, we can't send null as the GQL syntax validation fails. }
joblineStatus: bodyshop.md_order_statuses.default_returned, onClick={addToInventory}
inv: { >
shopid: bodyshop.id, <FileAddFilled/>
billlineid: billline.id, {billline?.inventories?.length > 0 && (
actual_price: billline.actual_price, <div>({billline?.inventories?.length} in inv)</div>
actual_cost: billline.actual_cost, )}
quantity: billline.quantity, </Button>
line_desc: billline.line_desc, </Tooltip>
}, );
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 (
<Tooltip title={t("inventory.actions.addtoinventory")}>
<Button
loading={loading}
disabled={
disabled || billline?.inventories?.length >= billline.quantity
}
onClick={addToInventory}
>
<FileAddFilled />
{billline?.inventories?.length > 0 && (
<div>({billline?.inventories?.length} in inv)</div>
)}
</Button>
</Tooltip>
);
} }

View File

@@ -1,235 +1,236 @@
import { EditFilled, SyncOutlined } from "@ant-design/icons"; import {EditFilled, SyncOutlined} from "@ant-design/icons";
import { Button, Card, Checkbox, Input, Space, Table } from "antd"; import {Button, Card, Checkbox, Input, Space, Table} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import {selectJobReadOnly} from "../../redux/application/application.selectors";
import { setModalContext } from "../../redux/modals/modals.actions"; import {setModalContext} from "../../redux/modals/modals.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import CurrencyFormatter from "../../utils/CurrencyFormatter"; import CurrencyFormatter from "../../utils/CurrencyFormatter";
import { DateFormatter } from "../../utils/DateFormatter"; import {DateFormatter} from "../../utils/DateFormatter";
import { alphaSort, dateSort } from "../../utils/sorters"; import {alphaSort, dateSort} from "../../utils/sorters";
import { TemplateList } from "../../utils/TemplateConstants"; import {TemplateList} from "../../utils/TemplateConstants";
import BillDeleteButton from "../bill-delete-button/bill-delete-button.component"; import BillDeleteButton from "../bill-delete-button/bill-delete-button.component";
import BillDetailEditReturnComponent from "../bill-detail-edit/bill-detail-edit-return.component"; import BillDetailEditReturnComponent from "../bill-detail-edit/bill-detail-edit-return.component";
import PrintWrapperComponent from "../print-wrapper/print-wrapper.component"; import PrintWrapperComponent from "../print-wrapper/print-wrapper.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
jobRO: selectJobReadOnly, jobRO: selectJobReadOnly,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setPartsOrderContext: (context) => setPartsOrderContext: (context) =>
dispatch(setModalContext({ context: context, modal: "partsOrder" })), dispatch(setModalContext({context: context, modal: "partsOrder"})),
setBillEnterContext: (context) => setBillEnterContext: (context) =>
dispatch(setModalContext({ context: context, modal: "billEnter" })), dispatch(setModalContext({context: context, modal: "billEnter"})),
setReconciliationContext: (context) => setReconciliationContext: (context) =>
dispatch(setModalContext({ context: context, modal: "reconciliation" })), dispatch(setModalContext({context: context, modal: "reconciliation"})),
}); });
export function BillsListTableComponent({ export function BillsListTableComponent({
bodyshop, bodyshop,
jobRO, jobRO,
job, job,
billsQuery, billsQuery,
handleOnRowClick, handleOnRowClick,
setPartsOrderContext, setPartsOrderContext,
setBillEnterContext, setBillEnterContext,
setReconciliationContext, setReconciliationContext,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
}); });
// const search = queryString.parse(useLocation().search); // const search = queryString.parse(useLocation().search);
// const selectedBill = search.billid; // const selectedBill = search.billid;
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const Templates = TemplateList("bill"); const Templates = TemplateList("bill");
const bills = billsQuery.data ? billsQuery.data.bills : []; const bills = billsQuery.data ? billsQuery.data.bills : [];
const { refetch } = billsQuery; const {refetch} = billsQuery;
const recordActions = (record, showView = false) => ( const recordActions = (record, showView = false) => (
<Space wrap>
{showView && (
<Button onClick={() => handleOnRowClick(record)}>
<EditFilled />
</Button>
)}
<BillDeleteButton bill={record} />
<BillDetailEditReturnComponent
data={{ bills_by_pk: { ...record, jobid: job.id } }}
disabled={
record.is_credit_memo ||
record.vendorid === bodyshop.inhousevendorid ||
jobRO
}
/>
{record.isinhouse && (
<PrintWrapperComponent
templateObject={{
name: Templates.inhouse_invoice.key,
variables: { id: record.id },
}}
messageObject={{ subject: Templates.inhouse_invoice.subject }}
/>
)}
</Space>
);
const columns = [
{
title: t("bills.fields.vendorname"),
dataIndex: "vendorname",
key: "vendorname",
sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
sortOrder:
state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
render: (text, record) => <span>{record.vendor.name}</span>,
},
{
title: t("bills.fields.invoice_number"),
dataIndex: "invoice_number",
key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder:
state.sortedInfo.columnKey === "invoice_number" &&
state.sortedInfo.order,
},
{
title: t("bills.fields.date"),
dataIndex: "date",
key: "date",
sorter: (a, b) => dateSort(a.date, b.date),
sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
},
{
title: t("bills.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.total}</CurrencyFormatter>
),
},
{
title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.is_credit_memo} />,
},
{
title: t("bills.fields.exported"),
dataIndex: "exported",
key: "exported",
sorter: (a, b) => a.exported - b.exported,
sortOrder:
state.sortedInfo.columnKey === "exported" && state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.exported} />,
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
render: (text, record) => recordActions(record, true),
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
};
const filteredBills = bills
? searchText === ""
? bills
: bills.filter(
(b) =>
(b.invoice_number || "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
(b.vendor.name || "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
(b.total || "")
.toString()
.toLowerCase()
.includes(searchText.toLowerCase())
)
: [];
return (
<Card
title={t("bills.labels.bills")}
extra={
<Space wrap> <Space wrap>
<Button onClick={() => refetch()}> {showView && (
<SyncOutlined /> <Button onClick={() => handleOnRowClick(record)}>
</Button> <EditFilled/>
{job && job.converted ? ( </Button>
<> )}
<Button <BillDeleteButton bill={record}/>
onClick={() => { <BillDetailEditReturnComponent
setBillEnterContext({ data={{bills_by_pk: {...record, jobid: job.id}}}
actions: { refetch: billsQuery.refetch }, disabled={
context: { record.is_credit_memo ||
job, record.vendorid === bodyshop.inhousevendorid ||
}, jobRO
}); }
}} />
>
{t("jobs.actions.postbills")}
</Button>
<Button
onClick={() => {
setReconciliationContext({
actions: { refetch: billsQuery.refetch },
context: {
job,
bills: (billsQuery.data && billsQuery.data.bills) || [],
},
});
}}
>
{t("jobs.actions.reconcile")}
</Button>
</>
) : null}
<Input.Search {record.isinhouse && (
placeholder={t("general.labels.search")} <PrintWrapperComponent
value={searchText} templateObject={{
onChange={(e) => { name: Templates.inhouse_invoice.key,
e.preventDefault(); variables: {id: record.id},
setSearchText(e.target.value); }}
}} messageObject={{subject: Templates.inhouse_invoice.subject}}
/> />
)}
</Space> </Space>
} );
> const columns = [
<Table {
loading={billsQuery.loading} title: t("bills.fields.vendorname"),
scroll={{ dataIndex: "vendorname",
x: true, // y: "50rem" key: "vendorname",
}} sorter: (a, b) => alphaSort(a.vendor.name, b.vendor.name),
columns={columns} sortOrder:
rowKey="id" state.sortedInfo.columnKey === "vendorname" && state.sortedInfo.order,
dataSource={filteredBills} render: (text, record) => <span>{record.vendor.name}</span>,
onChange={handleTableChange} },
/> {
</Card> title: t("bills.fields.invoice_number"),
); dataIndex: "invoice_number",
key: "invoice_number",
sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
sortOrder:
state.sortedInfo.columnKey === "invoice_number" &&
state.sortedInfo.order,
},
{
title: t("bills.fields.date"),
dataIndex: "date",
key: "date",
sorter: (a, b) => dateSort(a.date, b.date),
sortOrder:
state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
render: (text, record) => <DateFormatter>{record.date}</DateFormatter>,
},
{
title: t("bills.fields.total"),
dataIndex: "total",
key: "total",
sorter: (a, b) => a.total - b.total,
sortOrder:
state.sortedInfo.columnKey === "total" && state.sortedInfo.order,
render: (text, record) => (
<CurrencyFormatter>{record.total}</CurrencyFormatter>
),
},
{
title: t("bills.fields.is_credit_memo"),
dataIndex: "is_credit_memo",
key: "is_credit_memo",
sorter: (a, b) => a.is_credit_memo - b.is_credit_memo,
sortOrder:
state.sortedInfo.columnKey === "is_credit_memo" &&
state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.is_credit_memo}/>,
},
{
title: t("bills.fields.exported"),
dataIndex: "exported",
key: "exported",
sorter: (a, b) => a.exported - b.exported,
sortOrder:
state.sortedInfo.columnKey === "exported" && state.sortedInfo.order,
render: (text, record) => <Checkbox checked={record.exported}/>,
},
{
title: t("general.labels.actions"),
dataIndex: "actions",
key: "actions",
render: (text, record) => recordActions(record, true),
},
];
const handleTableChange = (pagination, filters, sorter) => {
setState({...state, filteredInfo: filters, sortedInfo: sorter});
};
const filteredBills = bills
? searchText === ""
? bills
: bills.filter(
(b) =>
(b.invoice_number || "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
(b.vendor.name || "")
.toLowerCase()
.includes(searchText.toLowerCase()) ||
(b.total || "")
.toString()
.toLowerCase()
.includes(searchText.toLowerCase())
)
: [];
return (
<Card
title={t("bills.labels.bills")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined/>
</Button>
{job && job.converted ? (
<>
<Button
onClick={() => {
setBillEnterContext({
actions: {refetch: billsQuery.refetch},
context: {
job,
},
});
}}
>
{t("jobs.actions.postbills")}
</Button>
<Button
onClick={() => {
setReconciliationContext({
actions: {refetch: billsQuery.refetch},
context: {
job,
bills: (billsQuery.data && billsQuery.data.bills) || [],
},
});
}}
>
{t("jobs.actions.reconcile")}
</Button>
</>
) : null}
<Input.Search
placeholder={t("general.labels.search")}
value={searchText}
onChange={(e) => {
e.preventDefault();
setSearchText(e.target.value);
}}
/>
</Space>
}
>
<Table
loading={billsQuery.loading}
scroll={{
x: true, // y: "50rem"
}}
columns={columns}
rowKey="id"
dataSource={filteredBills}
onChange={handleTableChange}
/>
</Card>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(BillsListTableComponent); )(BillsListTableComponent);

View File

@@ -1,121 +1,121 @@
import React, { useState } from "react"; import React, {useState} from "react";
import { QUERY_ALL_VENDORS } from "../../graphql/vendors.queries"; import {QUERY_ALL_VENDORS} from "../../graphql/vendors.queries";
import { useQuery } from "@apollo/client"; import {useQuery} from "@apollo/client";
import queryString from "query-string"; import queryString from "query-string";
import { useHistory, useLocation } from "react-router-dom"; import {useLocation, useNavigate} from "react-router-dom";
import { Table, Input } from "antd"; import {Input, Table} from "antd";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { alphaSort } from "../../utils/sorters"; import {alphaSort} from "../../utils/sorters";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
export default function BillsVendorsList() { export default function BillsVendorsList() {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const history = useHistory(); const history = useNavigate();
const { loading, error, data } = useQuery(QUERY_ALL_VENDORS, { const {loading, error, data} = useQuery(QUERY_ALL_VENDORS, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
}); });
const { t } = useTranslation(); const {t} = useTranslation();
const [state, setState] = useState({ const [state, setState] = useState({
sortedInfo: {}, sortedInfo: {},
search: "", search: "",
}); });
const handleTableChange = (pagination, filters, sorter) => { const handleTableChange = (pagination, filters, sorter) => {
setState({ ...state, filteredInfo: filters, sortedInfo: sorter }); setState({...state, filteredInfo: filters, sortedInfo: sorter});
}; };
const columns = [ const columns = [
{ {
title: t("vendors.fields.name"), title: t("vendors.fields.name"),
dataIndex: "name", dataIndex: "name",
key: "name", key: "name",
sorter: (a, b) => alphaSort(a.name, b.name), sorter: (a, b) => alphaSort(a.name, b.name),
sortOrder: sortOrder:
state.sortedInfo.columnKey === "name" && state.sortedInfo.order, state.sortedInfo.columnKey === "name" && state.sortedInfo.order,
},
{
title: t("vendors.fields.cost_center"),
dataIndex: "cost_center",
key: "cost_center",
sorter: (a, b) => alphaSort(a.cost_center, b.cost_center),
sortOrder:
state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order,
},
{
title: t("vendors.fields.city"),
dataIndex: "city",
key: "city",
},
];
const handleOnRowClick = (record) => {
if (record) {
delete search.billid;
if (record.id) {
search.vendorid = record.id;
history.push({ search: queryString.stringify(search) });
}
} else {
delete search.vendorid;
history.push({ search: queryString.stringify(search) });
}
};
const handleSearch = (e) => {
setState({ ...state, search: e.target.value });
};
if (error) return <AlertComponent message={error.message} type="error" />;
const dataSource = state.search
? data.vendors.filter(
(v) =>
(v.name || "").toLowerCase().includes(state.search.toLowerCase()) ||
(v.cost_center || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.city || "").toLowerCase().includes(state.search.toLowerCase())
)
: (data && data.vendors) || [];
return (
<Table
loading={loading}
title={() => {
return (
<div>
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</div>
);
}}
dataSource={dataSource}
pagination={{ position: "top" }}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelect: (record) => {
handleOnRowClick(record);
}, },
selectedRowKeys: [search.vendorid], {
type: "radio", title: t("vendors.fields.cost_center"),
}} dataIndex: "cost_center",
onRow={(record, rowIndex) => { key: "cost_center",
return { sorter: (a, b) => alphaSort(a.cost_center, b.cost_center),
onClick: (event) => { sortOrder:
handleOnRowClick(record); state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order,
}, // click row },
}; {
}} title: t("vendors.fields.city"),
/> dataIndex: "city",
); key: "city",
},
];
const handleOnRowClick = (record) => {
if (record) {
delete search.billid;
if (record.id) {
search.vendorid = record.id;
history.push({search: queryString.stringify(search)});
}
} else {
delete search.vendorid;
history.push({search: queryString.stringify(search)});
}
};
const handleSearch = (e) => {
setState({...state, search: e.target.value});
};
if (error) return <AlertComponent message={error.message} type="error"/>;
const dataSource = state.search
? data.vendors.filter(
(v) =>
(v.name || "").toLowerCase().includes(state.search.toLowerCase()) ||
(v.cost_center || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.city || "").toLowerCase().includes(state.search.toLowerCase())
)
: (data && data.vendors) || [];
return (
<Table
loading={loading}
title={() => {
return (
<div>
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
/>
</div>
);
}}
dataSource={dataSource}
pagination={{position: "top"}}
columns={columns}
rowKey="id"
onChange={handleTableChange}
rowSelection={{
onSelect: (record) => {
handleOnRowClick(record);
},
selectedRowKeys: [search.vendorid],
type: "radio",
}}
onRow={(record, rowIndex) => {
return {
onClick: (event) => {
handleOnRowClick(record);
}, // click row
};
}}
/>
);
} }

View File

@@ -1,54 +1,64 @@
import { HomeFilled } from "@ant-design/icons"; import {HomeFilled} from "@ant-design/icons";
import { Breadcrumb, Row, Col } from "antd"; import {Breadcrumb, Col, Row} from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBreadcrumbs } from "../../redux/application/application.selectors"; import {selectBreadcrumbs} from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import GlobalSearch from "../global-search/global-search.component"; import GlobalSearch from "../global-search/global-search.component";
import GlobalSearchOs from "../global-search/global-search-os.component"; import GlobalSearchOs from "../global-search/global-search-os.component";
import "./breadcrumbs.styles.scss"; import "./breadcrumbs.styles.scss";
import { useTreatments } from "@splitsoftware/splitio-react"; import {useSplitTreatments} from "@splitsoftware/splitio-react";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
breadcrumbs: selectBreadcrumbs, breadcrumbs: selectBreadcrumbs,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
export function BreadCrumbs({ breadcrumbs, bodyshop }) { export function BreadCrumbs({breadcrumbs, bodyshop}) {
const { OpenSearch } = useTreatments(
["OpenSearch"],
{},
bodyshop && bodyshop.imexshopid
);
return ( const {treatments: {OpenSearch}} = useSplitTreatments({
<Row className="breadcrumb-container"> attributes: {},
<Col xs={24} sm={24} md={16}> names: ["OpenSearch"],
<Breadcrumb separator=">"> splitKey: bodyshop && bodyshop.imexshopid,
<Breadcrumb.Item> });
<Link to={`/manage`}> // TODO - Client Update - Technically key is not doing anything here
<HomeFilled />{" "} return (
{(bodyshop && bodyshop.shopname && `(${bodyshop.shopname})`) || <Row className="breadcrumb-container">
""} <Col xs={24} sm={24} md={16}>
</Link> <Breadcrumb
</Breadcrumb.Item> separator=">"
{breadcrumbs.map((item) => items={[
item.link ? ( {
<Breadcrumb.Item key={item.label}> key: "home",
<Link to={item.link}>{item.label} </Link> title: (
</Breadcrumb.Item> <Link to={`/manage/`}>
) : ( <HomeFilled/>{" "}
<Breadcrumb.Item key={item.label}>{item.label}</Breadcrumb.Item> {(bodyshop && bodyshop.shopname && `(${bodyshop.shopname})`) ||
) ""}
)} </Link>
</Breadcrumb> ),
</Col> },
<Col xs={24} sm={24} md={8}> ...breadcrumbs.map((item) =>
{OpenSearch.treatment === "on" ? <GlobalSearchOs /> : <GlobalSearch />} item.link
</Col> ? {
</Row> key: item.label,
); title: <Link to={item.link}>{item.label}</Link>,
}
: {
key: item.label,
title: item.label,
}
),
]}
/>
</Col>
<Col xs={24} sm={24} md={8}>
{OpenSearch.treatment === "on" ? <GlobalSearchOs/> : <GlobalSearch/>}
</Col>
</Row>
);
} }
export default connect(mapStateToProps, null)(BreadCrumbs); export default connect(mapStateToProps, null)(BreadCrumbs);

View File

@@ -1,99 +1,99 @@
import { Button, Form, Modal } from "antd"; import {Button, Form, Modal} from "antd";
import React, { useEffect, useState } from "react"; import React, {useEffect, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import {toggleModalVisible} from "../../redux/modals/modals.actions";
import { selectCaBcEtfTableConvert } from "../../redux/modals/modals.selectors"; import {selectCaBcEtfTableConvert} from "../../redux/modals/modals.selectors";
import { GenerateDocument } from "../../utils/RenderTemplate"; import {GenerateDocument} from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import {TemplateList} from "../../utils/TemplateConstants";
import CaBcEtfTableModalComponent from "./ca-bc-etf-table.modal.component"; import CaBcEtfTableModalComponent from "./ca-bc-etf-table.modal.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
caBcEtfTableModal: selectCaBcEtfTableConvert, caBcEtfTableModal: selectCaBcEtfTableConvert,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => toggleModalVisible: () =>
dispatch(toggleModalVisible("ca_bc_eftTableConvert")), dispatch(toggleModalVisible("ca_bc_eftTableConvert")),
}); });
export function ContractsFindModalContainer({ export function ContractsFindModalContainer({
caBcEtfTableModal, caBcEtfTableModal,
toggleModalVisible, toggleModalVisible,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const { visible } = caBcEtfTableModal; const {open} = caBcEtfTableModal;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const EtfTemplate = TemplateList("special").ca_bc_etf_table; const EtfTemplate = TemplateList("special").ca_bc_etf_table;
const handleFinish = async (values) => { const handleFinish = async (values) => {
logImEXEvent("ca_bc_etf_table_parse"); logImEXEvent("ca_bc_etf_table_parse");
setLoading(true); setLoading(true);
const claimNumbers = []; const claimNumbers = [];
values.table.split("\n").forEach((row, idx, arr) => { values.table.split("\n").forEach((row, idx, arr) => {
const { 1: claim, 2: shortclaim, 4: amount } = row.split("\t"); const {1: claim, 2: shortclaim, 4: amount} = row.split("\t");
if (!claim || !shortclaim) return; if (!claim || !shortclaim) return;
const trimmedShortClaim = shortclaim.trim(); const trimmedShortClaim = shortclaim.trim();
// const trimmedClaim = claim.trim(); // const trimmedClaim = claim.trim();
if (amount.slice(-1) === "-") { if (amount.slice(-1) === "-") {
} }
claimNumbers.push({ claimNumbers.push({
claim: trimmedShortClaim, claim: trimmedShortClaim,
amount: amount.slice(-1) === "-" ? parseFloat(amount) * -1 : amount, amount: amount.slice(-1) === "-" ? parseFloat(amount) * -1 : amount,
}); });
}); });
await GenerateDocument( await GenerateDocument(
{ {
name: EtfTemplate.key, name: EtfTemplate.key,
variables: { variables: {
claimNumbers: `%(${claimNumbers.map((c) => c.claim).join("|")})%`, claimNumbers: `%(${claimNumbers.map((c) => c.claim).join("|")})%`,
claimdata: claimNumbers, claimdata: claimNumbers,
}, },
}, },
{}, {},
values.sendby === "email" ? "e" : "p" values.sendby === "email" ? "e" : "p"
);
setLoading(false);
};
useEffect(() => {
if (open) {
form.resetFields();
}
}, [open, form]);
return (
<Modal
open={open}
width="70%"
title={t("payments.labels.findermodal")}
onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()}
destroyOnClose
forceRender
>
<Form
form={form}
layout="vertical"
autoComplete="no"
onFinish={handleFinish}
>
<CaBcEtfTableModalComponent form={form}/>
<Button onClick={() => form.submit()} type="primary" loading={loading}>
{t("general.labels.search")}
</Button>
</Form>
</Modal>
); );
setLoading(false);
};
useEffect(() => {
if (visible) {
form.resetFields();
}
}, [visible, form]);
return (
<Modal
visible={visible}
width="70%"
title={t("payments.labels.findermodal")}
onCancel={() => toggleModalVisible()}
onOk={() => toggleModalVisible()}
destroyOnClose
forceRender
>
<Form
form={form}
layout="vertical"
autoComplete="no"
onFinish={handleFinish}
>
<CaBcEtfTableModalComponent form={form} />
<Button onClick={() => form.submit()} type="primary" loading={loading}>
{t("general.labels.search")}
</Button>
</Form>
</Modal>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(ContractsFindModalContainer); )(ContractsFindModalContainer);

View File

@@ -1,42 +1,42 @@
import { Form, Input, Radio } from "antd"; import {Form, Input, Radio} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
export default connect(mapStateToProps, null)(PartsReceiveModalComponent); export default connect(mapStateToProps, null)(PartsReceiveModalComponent);
export function PartsReceiveModalComponent({ bodyshop, form }) { export function PartsReceiveModalComponent({bodyshop, form}) {
const { t } = useTranslation(); const {t} = useTranslation();
return ( return (
<div> <div>
<Form.Item <Form.Item
name="table" name="table"
rules={[ rules={[
{ {
required: true, required: true,
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
]} ]}
> >
<Input.TextArea rows={8} /> <Input.TextArea rows={8}/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t("general.labels.sendby")} label={t("general.labels.sendby")}
name="sendby" name="sendby"
initialValue="print" initialValue="print"
> >
<Radio.Group> <Radio.Group>
<Radio value="email">{t("general.labels.email")}</Radio> <Radio value="email">{t("general.labels.email")}</Radio>
<Radio value="print">{t("general.labels.print")}</Radio> <Radio value="print">{t("general.labels.print")}</Radio>
</Radio.Group> </Radio.Group>
</Form.Item> </Form.Item>
</div> </div>
); );
} }

View File

@@ -1,49 +1,50 @@
import React, { useState } from "react"; import React, {useState} from "react";
import { Button, Form, InputNumber, Popover } from "antd"; import {Button, Form, InputNumber, Popover} from "antd";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { CalculatorFilled } from "@ant-design/icons"; import {CalculatorFilled} from "@ant-design/icons";
export default function CABCpvrtCalculator({ disabled, form }) {
const [visibility, setVisibility] = useState(false);
const { t } = useTranslation(); export default function CABCpvrtCalculator({disabled, form}) {
const [visibility, setVisibility] = useState(false);
const handleFinish = async (values) => { const {t} = useTranslation();
logImEXEvent("job_ca_bc_pvrt_calculate");
form.setFieldsValue({
ca_bc_pvrt: ((values.rate || 0) * (values.days || 0)).toFixed(2),
});
form.setFields([{ name: "ca_bc_pvrt", touched: true }]);
setVisibility(false);
};
const popContent = ( const handleFinish = async (values) => {
<div> logImEXEvent("job_ca_bc_pvrt_calculate");
<Form onFinish={handleFinish} initialValues={{ rate: 1.5 }}> form.setFieldsValue({
<Form.Item name="rate" label={t("jobs.labels.ca_bc_pvrt.rate")}> ca_bc_pvrt: ((values.rate || 0) * (values.days || 0)).toFixed(2),
<InputNumber precision={2} min={0} /> });
</Form.Item> form.setFields([{name: "ca_bc_pvrt", touched: true}]);
<Form.Item name="days" label={t("jobs.labels.ca_bc_pvrt.days")}> setVisibility(false);
<InputNumber precision={0} min={0} /> };
</Form.Item>
<Button type="primary" htmlType="submit">
{t("general.actions.calculate")}
</Button>
<Button onClick={() => setVisibility(false)}>Close</Button>
</Form>
</div>
);
return ( const popContent = (
<Popover <div>
destroyTooltipOnHide <Form onFinish={handleFinish} initialValues={{rate: 1.5}}>
content={popContent} <Form.Item name="rate" label={t("jobs.labels.ca_bc_pvrt.rate")}>
visible={visibility} <InputNumber precision={2} min={0}/>
disabled={disabled} </Form.Item>
> <Form.Item name="days" label={t("jobs.labels.ca_bc_pvrt.days")}>
<Button disabled={disabled} onClick={() => setVisibility(true)}> <InputNumber precision={0} min={0}/>
<CalculatorFilled /> </Form.Item>
</Button> <Button type="primary" htmlType="submit">
</Popover> {t("general.actions.calculate")}
); </Button>
<Button onClick={() => setVisibility(false)}>Close</Button>
</Form>
</div>
);
return (
<Popover
destroyTooltipOnHide
content={popContent}
open={visibility}
disabled={disabled}
>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled/>
</Button>
</Popover>
);
} }

View File

@@ -1,374 +1,360 @@
import { DeleteFilled } from "@ant-design/icons"; import {DeleteFilled} from "@ant-design/icons";
import { useLazyQuery, useMutation } from "@apollo/client"; import {useLazyQuery, useMutation} from "@apollo/client";
import { import {Button, Card, Col, Form, Input, notification, Row, Space, Spin, Statistic,} from "antd";
Button,
Card,
Col,
Form,
Input,
Row,
Space,
Spin,
Statistic,
notification,
} from "antd";
import axios from "axios"; import axios from "axios";
import moment from "moment"; import dayjs from "../../utils/day";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { import {INSERT_PAYMENT_RESPONSE, QUERY_RO_AND_OWNER_BY_JOB_PKS,} from "../../graphql/payment_response.queries";
INSERT_PAYMENT_RESPONSE, import {INSERT_NEW_PAYMENT} from "../../graphql/payments.queries";
QUERY_RO_AND_OWNER_BY_JOB_PKS, import {insertAuditTrail} from "../../redux/application/application.actions";
} from "../../graphql/payment_response.queries"; import {toggleModalVisible} from "../../redux/modals/modals.actions";
import { INSERT_NEW_PAYMENT } from "../../graphql/payments.queries"; import {selectCardPayment} from "../../redux/modals/modals.selectors";
import { insertAuditTrail } from "../../redux/application/application.actions"; import {selectBodyshop} from "../../redux/user/user.selectors";
import { toggleModalVisible } from "../../redux/modals/modals.actions";
import { selectCardPayment } from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component"; import CurrencyFormItemComponent from "../form-items-formatted/currency-form-item.component";
import JobSearchSelectComponent from "../job-search-select/job-search-select.component"; import JobSearchSelectComponent from "../job-search-select/job-search-select.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment, cardPaymentModal: selectCardPayment,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation }) => insertAuditTrail: ({jobid, operation}) =>
dispatch(insertAuditTrail({ jobid, operation })), dispatch(insertAuditTrail({jobid, operation})),
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")), toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")),
}); });
const CardPaymentModalComponent = ({ const CardPaymentModalComponent = ({
bodyshop, bodyshop,
cardPaymentModal, cardPaymentModal,
toggleModalVisible, toggleModalVisible,
insertAuditTrail, insertAuditTrail,
}) => { }) => {
const { context } = cardPaymentModal; const {context} = cardPaymentModal;
const [form] = Form.useForm(); const [form] = Form.useForm();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [insertPayment] = useMutation(INSERT_NEW_PAYMENT); const [insertPayment] = useMutation(INSERT_NEW_PAYMENT);
const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE); const [insertPaymentResponse] = useMutation(INSERT_PAYMENT_RESPONSE);
const { t } = useTranslation(); const {t} = useTranslation();
const [, { data, refetch, queryLoading }] = useLazyQuery( const [, {data, refetch, queryLoading}] = useLazyQuery(
QUERY_RO_AND_OWNER_BY_JOB_PKS, QUERY_RO_AND_OWNER_BY_JOB_PKS,
{ {
variables: { jobids: [context.jobid] }, variables: {jobids: [context.jobid]},
skip: true, skip: true,
} }
); );
console.log("🚀 ~ file: card-payment-modal.component..jsx:61 ~ data:", data); console.log("🚀 ~ file: card-payment-modal.component..jsx:61 ~ data:", data);
//Initialize the intellipay window. //Initialize the intellipay window.
const SetIntellipayCallbackFunctions = () => { const SetIntellipayCallbackFunctions = () => {
console.log("*** Set IntelliPay callback functions."); console.log("*** Set IntelliPay callback functions.");
window.intellipay.runOnClose(() => { window.intellipay.runOnClose(() => {
//window.intellipay.initialize(); //window.intellipay.initialize();
}); });
window.intellipay.runOnApproval(async function (response) { window.intellipay.runOnApproval(async function (response) {
console.warn("*** Running On Approval Script ***"); console.warn("*** Running On Approval Script ***");
form.setFieldValue("paymentResponse", response); form.setFieldValue("paymentResponse", response);
form.submit(); form.submit();
}); });
window.intellipay.runOnNonApproval(async function (response) { window.intellipay.runOnNonApproval(async function (response) {
// Mutate unsuccessful payment // Mutate unsuccessful payment
const { payments } = form.getFieldsValue(); const {payments} = form.getFieldsValue();
await insertPaymentResponse({ await insertPaymentResponse({
variables: { variables: {
paymentResponse: payments.map((payment) => ({ paymentResponse: payments.map((payment) => ({
amount: payment.amount, amount: payment.amount,
bodyshopid: bodyshop.id, bodyshopid: bodyshop.id,
jobid: payment.jobid, jobid: payment.jobid,
declinereason: response.declinereason, declinereason: response.declinereason,
ext_paymentid: response.paymentid.toString(), ext_paymentid: response.paymentid.toString(),
successful: false, successful: false,
response, response,
})), })),
},
});
payments.forEach((payment) =>
insertAuditTrail({
jobid: payment.jobid,
operation: AuditTrailMapping.failedpayment(),
})
);
});
};
const handleFinish = async (values) => {
try {
await insertPayment({
variables: {
paymentInput: values.payments.map((payment) => ({
amount: payment.amount,
transactionid: (values.paymentResponse.paymentid || "").toString(),
payer: t("payments.labels.customer"),
type: values.paymentResponse.cardbrand,
jobid: payment.jobid,
date: moment(Date.now()),
payment_responses: {
data: [
{
amount: payment.amount,
bodyshopid: bodyshop.id,
jobid: payment.jobid,
declinereason: values.paymentResponse.declinereason,
ext_paymentid: values.paymentResponse.paymentid.toString(),
successful: true,
response: values.paymentResponse,
}, },
], });
},
})),
},
refetchQueries: ["GET_JOB_BY_PK"],
});
toggleModalVisible();
} catch (error) {
console.error(error);
notification.open({
type: "error",
message: t("payments.errors.inserting", { error: error.message }),
});
} finally {
setLoading(false);
}
};
const handleIntelliPayCharge = async () => { payments.forEach((payment) =>
setLoading(true); insertAuditTrail({
jobid: payment.jobid,
operation: AuditTrailMapping.failedpayment(),
})
);
});
};
//Validate const handleFinish = async (values) => {
try { try {
await form.validateFields(); await insertPayment({
} catch (error) { variables: {
setLoading(false); paymentInput: values.payments.map((payment) => ({
return; amount: payment.amount,
} transactionid: (values.paymentResponse.paymentid || "").toString(),
payer: t("payments.labels.customer"),
type: values.paymentResponse.cardbrand,
jobid: payment.jobid,
date: dayjs(Date.now()),
payment_responses: {
data: [
{
amount: payment.amount,
bodyshopid: bodyshop.id,
try { jobid: payment.jobid,
const response = await axios.post("/intellipay/lightbox_credentials", { declinereason: values.paymentResponse.declinereason,
bodyshop, ext_paymentid: values.paymentResponse.paymentid.toString(),
refresh: !!window.intellipay, successful: true,
}); response: values.paymentResponse,
},
],
},
})),
},
refetchQueries: ["GET_JOB_BY_PK"],
});
toggleModalVisible();
} catch (error) {
console.error(error);
notification.open({
type: "error",
message: t("payments.errors.inserting", {error: error.message}),
});
} finally {
setLoading(false);
}
};
if (window.intellipay) { const handleIntelliPayCharge = async () => {
// eslint-disable-next-line no-eval setLoading(true);
eval(response.data);
SetIntellipayCallbackFunctions();
window.intellipay.autoOpen();
} else {
var rg = document.createRange();
let node = rg.createContextualFragment(response.data);
document.documentElement.appendChild(node);
SetIntellipayCallbackFunctions();
window.intellipay.isAutoOpen = true;
window.intellipay.initialize();
}
} catch (error) {
notification.open({
type: "error",
message: t("job_payments.notifications.error.openingip"),
});
setLoading(false);
}
};
return ( //Validate
<Card title="Card Payment"> try {
<Spin spinning={loading}> await form.validateFields();
<Form } catch (error) {
onFinish={handleFinish} setLoading(false);
form={form} return;
layout="vertical" }
initialValues={{
payments: context.jobid ? [{ jobid: context.jobid }] : [], try {
}} const response = await axios.post("/intellipay/lightbox_credentials", {
> bodyshop,
<Form.List name={["payments"]}> refresh: !!window.intellipay,
{(fields, { add, remove, move }) => { });
return (
<div> if (window.intellipay) {
{fields.map((field, index) => ( // eslint-disable-next-line no-eval
<Form.Item key={field.key}> eval(response.data);
<Row gutter={[16, 16]}> SetIntellipayCallbackFunctions();
<Col span={16}> window.intellipay.autoOpen();
<Form.Item } else {
key={`${index}jobid`} var rg = document.createRange();
label={t("jobs.fields.ro_number")} let node = rg.createContextualFragment(response.data);
name={[field.name, "jobid"]} document.documentElement.appendChild(node);
rules={[ SetIntellipayCallbackFunctions();
{ window.intellipay.isAutoOpen = true;
required: true, window.intellipay.initialize();
//message: t("general.validation.required"), }
}, } catch (error) {
]} notification.open({
> type: "error",
<JobSearchSelectComponent message: t("job_payments.notifications.error.openingip"),
notExported={false} });
clm_no setLoading(false);
/> }
</Form.Item> };
</Col>
<Col span={6}> return (
<Form.Item <Card title="Card Payment">
key={`${index}amount`} <Spin spinning={loading}>
label={t("payments.fields.amount")} <Form
name={[field.name, "amount"]} onFinish={handleFinish}
rules={[ form={form}
{ layout="vertical"
required: true, initialValues={{
//message: t("general.validation.required"), payments: context.jobid ? [{jobid: context.jobid}] : [],
}, }}
]} >
> <Form.List name={["payments"]}>
<CurrencyFormItemComponent /> {(fields, {add, remove, move}) => {
</Form.Item> return (
</Col> <div>
<Col span={2}> {fields.map((field, index) => (
<DeleteFilled <Form.Item key={field.key}>
style={{ margin: "1rem" }} <Row gutter={[16, 16]}>
onClick={() => { <Col span={16}>
remove(field.name); <Form.Item
}} key={`${index}jobid`}
/> label={t("jobs.fields.ro_number")}
</Col> name={[field.name, "jobid"]}
</Row> rules={[
</Form.Item> {
))} required: true,
<Form.Item> //message: t("general.validation.required"),
<Button },
type="dashed" ]}
onClick={() => { >
add(); <JobSearchSelectComponent
}} notExported={false}
style={{ width: "100%" }} clm_no
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
key={`${index}amount`}
label={t("payments.fields.amount")}
name={[field.name, "amount"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<CurrencyFormItemComponent/>
</Form.Item>
</Col>
<Col span={2}>
<DeleteFilled
style={{margin: "1rem"}}
onClick={() => {
remove(field.name);
}}
/>
</Col>
</Row>
</Form.Item>
))}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add();
}}
style={{width: "100%"}}
>
{t("general.actions.add")}
</Button>
</Form.Item>
</div>
);
}}
</Form.List>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.payments?.map((p) => p?.jobid).join() !==
curValues.payments?.map((p) => p?.jobid).join()
}
> >
{t("general.actions.add")} {() => {
</Button> console.log("Updating the owner info section.");
</Form.Item> //If all of the job ids have been fileld in, then query and update the IP field.
</div> const {payments} = form.getFieldsValue();
); if (
}} payments?.length > 0 &&
</Form.List> payments?.filter((p) => p?.jobid).length === payments?.length
) {
console.log("**Calling refetch.");
refetch({jobids: payments.map((p) => p.jobid)});
}
console.log(
"Acc info",
data,
payments && data && data.jobs.length > 0
? data.jobs.map((j) => j.ro_number).join(", ")
: null
);
return (
<>
<Input
className="ipayfield"
data-ipayname="account"
//type="hidden"
value={
payments && data && data.jobs.length > 0
? data.jobs.map((j) => j.ro_number).join(", ")
: null
}
hidden
/>
<Input
className="ipayfield"
data-ipayname="email"
// type="hidden"
value={
payments && data && data.jobs.length > 0
? data.jobs.filter((j) => j.ownr_ea)[0]?.ownr_ea
: null
}
hidden
/>
</>
);
}}
</Form.Item>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.payments?.map((p) => p?.amount).join() !==
curValues.payments?.map((p) => p?.amount).join()
}
>
{() => {
const {payments} = form.getFieldsValue();
const totalAmountToCharge = payments?.reduce((acc, val) => {
return acc + (val?.amount || 0);
}, 0);
<Form.Item return (
shouldUpdate={(prevValues, curValues) => <Space style={{float: "right"}}>
prevValues.payments?.map((p) => p?.jobid).join() !== <Statistic
curValues.payments?.map((p) => p?.jobid).join() title="Amount To Charge"
} value={totalAmountToCharge}
> precision={2}
{() => { />
console.log("Updating the owner info section."); <Input
//If all of the job ids have been fileld in, then query and update the IP field. className="ipayfield"
const { payments } = form.getFieldsValue(); data-ipayname="amount"
if ( //type="hidden"
payments?.length > 0 && value={totalAmountToCharge?.toFixed(2)}
payments?.filter((p) => p?.jobid).length === payments?.length hidden
) { />
console.log("**Calling refetch."); <Button
refetch({ jobids: payments.map((p) => p.jobid) }); type="primary"
} // data-ipayname="submit"
console.log( className="ipayfield"
"Acc info", loading={queryLoading || loading}
data, disabled={!(totalAmountToCharge > 0)}
payments && data && data.jobs.length > 0 onClick={handleIntelliPayCharge}
? data.jobs.map((j) => j.ro_number).join(", ") >
: null {t("job_payments.buttons.proceedtopayment")}
); </Button>
return ( </Space>
<> );
<Input }}
className="ipayfield" </Form.Item>
data-ipayname="account"
//type="hidden"
value={
payments && data && data.jobs.length > 0
? data.jobs.map((j) => j.ro_number).join(", ")
: null
}
hidden
/>
<Input
className="ipayfield"
data-ipayname="email"
// type="hidden"
value={
payments && data && data.jobs.length > 0
? data.jobs.filter((j) => j.ownr_ea)[0]?.ownr_ea
: null
}
hidden
/>
</>
);
}}
</Form.Item>
<Form.Item
shouldUpdate={(prevValues, curValues) =>
prevValues.payments?.map((p) => p?.amount).join() !==
curValues.payments?.map((p) => p?.amount).join()
}
>
{() => {
const { payments } = form.getFieldsValue();
const totalAmountToCharge = payments?.reduce((acc, val) => {
return acc + (val?.amount || 0);
}, 0);
return ( {/* Lightbox payment response when it is completed */}
<Space style={{ float: "right" }}> <Form.Item name="paymentResponse" hidden>
<Statistic <Input type="hidden"/>
title="Amount To Charge" </Form.Item>
value={totalAmountToCharge} </Form>
precision={2} </Spin>
/> </Card>
<Input );
className="ipayfield"
data-ipayname="amount"
//type="hidden"
value={totalAmountToCharge?.toFixed(2)}
hidden
/>
<Button
type="primary"
// data-ipayname="submit"
className="ipayfield"
loading={queryLoading || loading}
disabled={!(totalAmountToCharge > 0)}
onClick={handleIntelliPayCharge}
>
{t("job_payments.buttons.proceedtopayment")}
</Button>
</Space>
);
}}
</Form.Item>
{/* Lightbox payment response when it is completed */}
<Form.Item name="paymentResponse" hidden>
<Input type="hidden" />
</Form.Item>
</Form>
</Spin>
</Card>
);
}; };
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(CardPaymentModalComponent); )(CardPaymentModalComponent);

View File

@@ -1,57 +1,57 @@
import { Button, Modal } from "antd"; import {Button, Modal} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { toggleModalVisible } from "../../redux/modals/modals.actions"; import {toggleModalVisible} from "../../redux/modals/modals.actions";
import { selectCardPayment } from "../../redux/modals/modals.selectors"; import {selectCardPayment} from "../../redux/modals/modals.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import CardPaymentModalComponent from "./card-payment-modal.component."; import CardPaymentModalComponent from "./card-payment-modal.component.";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
cardPaymentModal: selectCardPayment, cardPaymentModal: selectCardPayment,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")), toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")),
}); });
function CardPaymentModalContainer({ function CardPaymentModalContainer({
cardPaymentModal, cardPaymentModal,
toggleModalVisible, toggleModalVisible,
bodyshop, bodyshop,
}) { }) {
const { visible } = cardPaymentModal; const {open} = cardPaymentModal;
const { t } = useTranslation(); const {t} = useTranslation();
const handleCancel = () => { const handleCancel = () => {
toggleModalVisible(); toggleModalVisible();
}; };
const handleOK = () => { const handleOK = () => {
toggleModalVisible(); toggleModalVisible();
}; };
return ( return (
<Modal <Modal
open={visible} open={open}
onOk={handleOK} onOk={handleOK}
onCancel={handleCancel} onCancel={handleCancel}
footer={[ footer={[
<Button key="back" onClick={handleCancel}> <Button key="back" onClick={handleCancel}>
{t("job_payments.buttons.goback")} {t("job_payments.buttons.goback")}
</Button>, </Button>,
]} ]}
width="80%" width="80%"
destroyOnClose destroyOnClose
> >
<CardPaymentModalComponent /> <CardPaymentModalComponent/>
</Modal> </Modal>
); );
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(CardPaymentModalContainer); )(CardPaymentModalContainer);

View File

@@ -1,106 +1,98 @@
import { useApolloClient } from "@apollo/client"; import {useApolloClient} from "@apollo/client";
import { getToken, onMessage } from "@firebase/messaging"; import {getToken, onMessage} from "@firebase/messaging";
import { Button, notification, Space } from "antd"; import {Button, notification, Space} from "antd";
import axios from "axios"; import axios from "axios";
import React, { useEffect } from "react"; import React, {useEffect} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {messaging, requestForToken} from "../../firebase/firebase.utils";
import { createStructuredSelector } from "reselect";
import { messaging, requestForToken } from "../../firebase/firebase.utils";
import { selectChatVisible } from "../../redux/messaging/messaging.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import FcmHandler from "../../utils/fcm-handler"; import FcmHandler from "../../utils/fcm-handler";
import ChatPopupComponent from "../chat-popup/chat-popup.component"; import ChatPopupComponent from "../chat-popup/chat-popup.component";
import "./chat-affix.styles.scss"; import "./chat-affix.styles.scss";
const mapStateToProps = createStructuredSelector({ export function ChatAffixContainer({bodyshop, chatVisible}) {
bodyshop: selectBodyshop, const {t} = useTranslation();
chatVisible: selectChatVisible, const client = useApolloClient();
}); useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
export function ChatAffixContainer({ bodyshop, chatVisible }) { async function SubscribeToTopic() {
const { t } = useTranslation(); try {
const client = useApolloClient(); const r = await axios.post("/notifications/subscribe", {
useEffect(() => { fcm_tokens: await getToken(messaging, {
if (!bodyshop || !bodyshop.messagingservicesid) return; vapidKey: process.env.REACT_APP_FIREBASE_PUBLIC_VAPID_KEY,
}),
type: "messaging",
imexshopid: bodyshop.imexshopid,
});
console.log("FCM Topic Subscription", r.data);
} catch (error) {
console.log(
"Error attempting to subscribe to messaging topic: ",
error
);
notification.open({
type: "warning",
message: t("general.errors.fcm"),
btn: (
<Space>
<Button
onClick={async () => {
await requestForToken();
SubscribeToTopic();
}}
>
{t("general.actions.tryagain")}
</Button>
<Button
onClick={() => {
const win = window.open(
"https://help.imex.online/en/article/enabling-notifications-o978xi/",
"_blank"
);
win.focus();
}}
>
{t("general.labels.help")}
</Button>
</Space>
),
});
}
}
async function SubscribeToTopic() { SubscribeToTopic();
try { // eslint-disable-next-line react-hooks/exhaustive-deps
const r = await axios.post("/notifications/subscribe", { }, [bodyshop]);
fcm_tokens: await getToken(messaging, {
vapidKey: process.env.REACT_APP_FIREBASE_PUBLIC_VAPID_KEY,
}),
type: "messaging",
imexshopid: bodyshop.imexshopid,
});
console.log("FCM Topic Subscription", r.data);
} catch (error) {
console.log(
"Error attempting to subscribe to messaging topic: ",
error
);
notification.open({
type: "warning",
message: t("general.errors.fcm"),
btn: (
<Space>
<Button
onClick={async () => {
await requestForToken();
SubscribeToTopic(); useEffect(() => {
}} function handleMessage(payload) {
> FcmHandler({
{t("general.actions.tryagain")} client,
</Button> payload: (payload && payload.data && payload.data.data) || payload.data,
<Button });
onClick={() => { }
const win = window.open(
"https://help.imex.online/en/article/enabling-notifications-o978xi/",
"_blank"
);
win.focus();
}}
>
{t("general.labels.help")}
</Button>
</Space>
),
});
}
}
SubscribeToTopic(); let stopMessageListener, channel;
// eslint-disable-next-line react-hooks/exhaustive-deps try {
}, [bodyshop]); stopMessageListener = onMessage(messaging, handleMessage);
channel = new BroadcastChannel("imex-sw-messages");
channel.addEventListener("message", handleMessage);
} catch (error) {
console.log("Unable to set event listeners.");
}
return () => {
stopMessageListener && stopMessageListener();
channel && channel.removeEventListener("message", handleMessage);
};
}, [client]);
useEffect(() => { if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
function handleMessage(payload) {
FcmHandler({
client,
payload: (payload && payload.data && payload.data.data) || payload.data,
});
}
let stopMessageListenr, channel;
try {
stopMessageListenr = onMessage(messaging, handleMessage);
channel = new BroadcastChannel("imex-sw-messages");
channel.addEventListener("message", handleMessage);
} catch (error) {
console.log("Unable to set event listeners.");
}
return () => {
stopMessageListenr && stopMessageListenr();
channel && channel.removeEventListener("message", handleMessage);
};
}, [client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>; return (
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
return ( {bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent/> : null}
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}> </div>
{bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent /> : null} );
</div>
);
} }
export default connect(mapStateToProps, null)(ChatAffixContainer);
export default ChatAffixContainer;

View File

@@ -1,29 +1,29 @@
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { Button } from "antd"; import {Button} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { TOGGLE_CONVERSATION_ARCHIVE } from "../../graphql/conversations.queries"; import {TOGGLE_CONVERSATION_ARCHIVE} from "../../graphql/conversations.queries";
export default function ChatArchiveButton({ conversation }) { export default function ChatArchiveButton({conversation}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useTranslation(); const {t} = useTranslation();
const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE); const [updateConversation] = useMutation(TOGGLE_CONVERSATION_ARCHIVE);
const handleToggleArchive = async () => { const handleToggleArchive = async () => {
setLoading(true); setLoading(true);
await updateConversation({ await updateConversation({
variables: { id: conversation.id, archived: !conversation.archived }, variables: {id: conversation.id, archived: !conversation.archived},
refetchQueries: ["CONVERSATION_LIST_QUERY"], refetchQueries: ["CONVERSATION_LIST_QUERY"],
}); });
setLoading(false); setLoading(false);
}; };
return ( return (
<Button onClick={handleToggleArchive} loading={loading} type="primary"> <Button onClick={handleToggleArchive} loading={loading} type="primary">
{conversation.archived {conversation.archived
? t("messaging.labels.unarchive") ? t("messaging.labels.unarchive")
: t("messaging.labels.archive")} : t("messaging.labels.archive")}
</Button> </Button>
); );
} }

View File

@@ -1,120 +1,122 @@
import { Badge, List, Tag } from "antd"; import {Badge, Card, List, Space, Tag} from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { import {AutoSizer, CellMeasurer, CellMeasurerCache, List as VirtualizedList,} from "react-virtualized";
AutoSizer, import {createStructuredSelector} from "reselect";
CellMeasurer, import {setSelectedConversation} from "../../redux/messaging/messaging.actions";
CellMeasurerCache, import {selectSelectedConversation} from "../../redux/messaging/messaging.selectors";
List as VirtualizedList, import {TimeAgoFormatter} from "../../utils/DateFormatter";
} from "react-virtualized";
import { createStructuredSelector } from "reselect";
import { setSelectedConversation } from "../../redux/messaging/messaging.actions";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import { TimeAgoFormatter } from "../../utils/DateFormatter";
import PhoneFormatter from "../../utils/PhoneFormatter"; import PhoneFormatter from "../../utils/PhoneFormatter";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import {OwnerNameDisplayFunction} from "../owner-name-display/owner-name-display.component";
import _ from "lodash";
import "./chat-conversation-list.styles.scss"; import "./chat-conversation-list.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setSelectedConversation: (conversationId) => setSelectedConversation: (conversationId) =>
dispatch(setSelectedConversation(conversationId)), dispatch(setSelectedConversation(conversationId)),
}); });
function ChatConversationListComponent({ function ChatConversationListComponent({
conversationList, conversationList,
selectedConversation, selectedConversation,
setSelectedConversation, setSelectedConversation,
loadMoreConversations, loadMoreConversations,
}) { }) {
const cache = new CellMeasurerCache({ const cache = new CellMeasurerCache({
fixedWidth: true, fixedWidth: true,
defaultHeight: 60, defaultHeight: 60,
}); });
const rowRenderer = ({ index, key, style, parent }) => { const rowRenderer = ({index, key, style, parent}) => {
const item = conversationList[index]; const item = conversationList[index];
const cardContentRight =
<TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>;
const cardContentLeft = item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => (
<Tag key={idx}>{j.job.ro_number}</Tag>
))
: null;
const names = <>{_.uniq(item.job_conversations.map((j, idx) =>
OwnerNameDisplayFunction(j.job)
))}</>
const cardTitle = <>
{item.label && <Tag color="blue">{item.label}</Tag>}
{item.job_conversations.length > 0 ? (
<Space direction="vertical">
{names}
</Space>
) : (
<Space>
<PhoneFormatter>{item.phone_num}</PhoneFormatter>
</Space>
)}
</>
const cardExtra = <Badge count={item.messages_aggregate.aggregate.count || 0}/>
const getCardStyle = () =>
item.id === selectedConversation
? {backgroundColor: 'rgba(128, 128, 128, 0.2)'}
: {backgroundColor: index % 2 === 0 ? '#f0f2f5' : '#ffffff'};
return (
<CellMeasurer
key={key}
cache={cache}
parent={parent}
columnIndex={0}
rowIndex={index}
>
<List.Item
onClick={() => setSelectedConversation(item.id)}
style={style}
className={`chat-list-item
${
item.id === selectedConversation
? "chat-list-selected-conversation"
: null
}`}
>
<Card style={getCardStyle()} bordered={false} size="small" extra={cardExtra} title={cardTitle}>
<div style={{display: 'inline-block', width: '70%', textAlign: 'left'}}>
{cardContentLeft}
</div>
<div
style={{display: 'inline-block', width: '30%', textAlign: 'right'}}>{cardContentRight}</div>
</Card>
</List.Item>
</CellMeasurer>
);
};
return ( return (
<CellMeasurer <div className="chat-list-container">
key={key} <AutoSizer>
cache={cache} {({height, width}) => (
parent={parent} <VirtualizedList
columnIndex={0} height={height}
rowIndex={index} width={width}
> rowCount={conversationList.length}
<List.Item rowHeight={cache.rowHeight}
onClick={() => setSelectedConversation(item.id)} rowRenderer={rowRenderer}
className={`chat-list-item ${ onScroll={({scrollTop, scrollHeight, clientHeight}) => {
item.id === selectedConversation if (scrollTop + clientHeight === scrollHeight) {
? "chat-list-selected-conversation" loadMoreConversations();
: null }
}`} }}
style={style} />
> )}
<div </AutoSizer>
style={{ </div>
display: "inline-block",
}}
>
{item.label && <div className="chat-name">{item.label}</div>}
{item.job_conversations.length > 0 ? (
<div className="chat-name">
{item.job_conversations.map((j, idx) => (
<div key={idx}>
<OwnerNameDisplay ownerObject={j.job} />
</div>
))}
</div>
) : (
<PhoneFormatter>{item.phone_num}</PhoneFormatter>
)}
</div>
<div style={{ display: "inline-block" }}>
<div>
{item.job_conversations.length > 0
? item.job_conversations.map((j, idx) => (
<Tag key={idx} className="ro-number-tag">
{j.job.ro_number}
</Tag>
))
: null}
</div>
<TimeAgoFormatter>{item.updated_at}</TimeAgoFormatter>
</div>
<Badge count={item.messages_aggregate.aggregate.count || 0} />
</List.Item>
</CellMeasurer>
); );
};
return (
<div className="chat-list-container">
<AutoSizer>
{({ height, width }) => (
<VirtualizedList
height={height}
width={width}
rowCount={conversationList.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
onScroll={({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight === scrollHeight) {
loadMoreConversations();
}
}}
/>
)}
</AutoSizer>
</div>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(ChatConversationListComponent); )(ChatConversationListComponent);

View File

@@ -1,27 +1,16 @@
.chat-list-selected-conversation {
background-color: rgba(128, 128, 128, 0.2);
}
.chat-list-container { .chat-list-container {
flex: 1;
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
border: 1px solid gainsboro; border: 1px solid gainsboro;
} }
.chat-list-item { .chat-list-item {
display: flex; .ant-card-head {
flex-direction: row; border: none;
}
&:hover { &:hover {
cursor: pointer; cursor: pointer;
color: #ff7a00; color: #ff7a00;
} }
.chat-name {
flex: 1;
display: inline;
}
.ro-number-tag {
align-self: baseline;
}
padding: 12px 24px;
border-bottom: 1px solid gainsboro;
} }

View File

@@ -1,56 +1,56 @@
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { Tag } from "antd"; import {Tag} from "antd";
import React from "react"; import React from "react";
import { Link } from "react-router-dom"; import {Link} from "react-router-dom";
import { logImEXEvent } from "../../firebase/firebase.utils"; import {logImEXEvent} from "../../firebase/firebase.utils";
import { REMOVE_CONVERSATION_TAG } from "../../graphql/job-conversations.queries"; import {REMOVE_CONVERSATION_TAG} from "../../graphql/job-conversations.queries";
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component"; import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
export default function ChatConversationTitleTags({ jobConversations }) { export default function ChatConversationTitleTags({jobConversations}) {
const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG); const [removeJobConversation] = useMutation(REMOVE_CONVERSATION_TAG);
const handleRemoveTag = (jobId) => { const handleRemoveTag = (jobId) => {
const convId = jobConversations[0].conversationid; const convId = jobConversations[0].conversationid;
if (!!convId) { if (!!convId) {
removeJobConversation({ removeJobConversation({
variables: { variables: {
conversationId: convId, conversationId: convId,
jobId: jobId, jobId: jobId,
}, },
update(cache) { update(cache) {
cache.modify({ cache.modify({
id: cache.identify({ id: convId, __typename: "conversations" }), id: cache.identify({id: convId, __typename: "conversations"}),
fields: { fields: {
job_conversations(ex) { job_conversations(ex) {
return ex.filter((e) => e.jobid !== jobId); return ex.filter((e) => e.jobid !== jobId);
}, },
}, },
}); });
}, },
}); });
logImEXEvent("messaging_remove_job_tag", { logImEXEvent("messaging_remove_job_tag", {
conversationId: convId, conversationId: convId,
jobId: jobId, jobId: jobId,
}); });
} }
}; };
return ( return (
<div> <div>
{jobConversations.map((item) => ( {jobConversations.map((item) => (
<Tag <Tag
key={item.job.id} key={item.job.id}
closable closable
color="blue" color="blue"
style={{ cursor: "pointer" }} style={{cursor: "pointer"}}
onClose={() => handleRemoveTag(item.job.id)} onClose={() => handleRemoveTag(item.job.id)}
> >
<Link to={`/manage/jobs/${item.job.id}`}> <Link to={`/manage/jobs/${item.job.id}`}>
{`${item.job.ro_number || "?"} | `} {`${item.job.ro_number || "?"} | `}
<OwnerNameDisplay ownerObject={item.job} /> <OwnerNameDisplay ownerObject={item.job}/>
</Link> </Link>
</Tag> </Tag>
))} ))}
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Space } from "antd"; import {Space} from "antd";
import React from "react"; import React from "react";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import ChatArchiveButton from "../chat-archive-button/chat-archive-button.component"; import ChatArchiveButton from "../chat-archive-button/chat-archive-button.component";
@@ -7,21 +7,21 @@ import ChatLabelComponent from "../chat-label/chat-label.component";
import ChatPrintButton from "../chat-print-button/chat-print-button.component"; import ChatPrintButton from "../chat-print-button/chat-print-button.component";
import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container"; import ChatTagRoContainer from "../chat-tag-ro/chat-tag-ro.container";
export default function ChatConversationTitle({ conversation }) { export default function ChatConversationTitle({conversation}) {
return ( return (
<Space wrap> <Space wrap>
<PhoneNumberFormatter> <PhoneNumberFormatter>
{conversation && conversation.phone_num} {conversation && conversation.phone_num}
</PhoneNumberFormatter> </PhoneNumberFormatter>
<ChatLabelComponent conversation={conversation} /> <ChatLabelComponent conversation={conversation}/>
<ChatPrintButton conversation={conversation} /> <ChatPrintButton conversation={conversation}/>
<ChatConversationTitleTags <ChatConversationTitleTags
jobConversations={ jobConversations={
(conversation && conversation.job_conversations) || [] (conversation && conversation.job_conversations) || []
} }
/> />
<ChatTagRoContainer conversation={conversation || []} /> <ChatTagRoContainer conversation={conversation || []}/>
<ChatArchiveButton conversation={conversation} /> <ChatArchiveButton conversation={conversation}/>
</Space> </Space>
); );
} }

View File

@@ -7,25 +7,25 @@ import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"
import "./chat-conversation.styles.scss"; import "./chat-conversation.styles.scss";
export default function ChatConversationComponent({ export default function ChatConversationComponent({
subState, subState,
conversation, conversation,
messages, messages,
handleMarkConversationAsRead, handleMarkConversationAsRead,
}) { }) {
const [loading, error] = subState; const [loading, error] = subState;
if (loading) return <LoadingSkeleton />; if (loading) return <LoadingSkeleton/>;
if (error) return <AlertComponent message={error.message} type="error" />; if (error) return <AlertComponent message={error.message} type="error"/>;
return ( return (
<div <div
className="chat-conversation" className="chat-conversation"
onMouseDown={handleMarkConversationAsRead} onMouseDown={handleMarkConversationAsRead}
onKeyDown={handleMarkConversationAsRead} onKeyDown={handleMarkConversationAsRead}
> >
<ChatConversationTitle conversation={conversation} /> <ChatConversationTitle conversation={conversation}/>
<ChatMessageListComponent messages={messages} /> <ChatMessageListComponent messages={messages}/>
<ChatSendMessage conversation={conversation} /> <ChatSendMessage conversation={conversation}/>
</div> </div>
); );
} }

View File

@@ -1,89 +1,87 @@
import { useMutation, useQuery, useSubscription } from "@apollo/client"; import {useMutation, useQuery, useSubscription} from "@apollo/client";
import React, { useState } from "react"; import React, {useState} from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { import {CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS,} from "../../graphql/conversations.queries";
CONVERSATION_SUBSCRIPTION_BY_PK, import {MARK_MESSAGES_AS_READ_BY_CONVERSATION} from "../../graphql/messages.queries";
GET_CONVERSATION_DETAILS, import {selectSelectedConversation} from "../../redux/messaging/messaging.selectors";
} from "../../graphql/conversations.queries";
import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import { selectSelectedConversation } from "../../redux/messaging/messaging.selectors";
import ChatConversationComponent from "./chat-conversation.component"; import ChatConversationComponent from "./chat-conversation.component";
import axios from "axios"; import axios from "axios";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
export default connect(mapStateToProps, null)(ChatConversationContainer); export default connect(mapStateToProps, null)(ChatConversationContainer);
export function ChatConversationContainer({ bodyshop, selectedConversation }) { export function ChatConversationContainer({bodyshop, selectedConversation}) {
const { const {
loading: convoLoading, loading: convoLoading,
error: convoError, error: convoError,
data: convoData, data: convoData,
} = useQuery(GET_CONVERSATION_DETAILS, { } = useQuery(GET_CONVERSATION_DETAILS, {
variables: { conversationId: selectedConversation }, variables: {conversationId: selectedConversation},
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
}); });
const { loading, error, data } = useSubscription( const {loading, error, data} = useSubscription(
CONVERSATION_SUBSCRIPTION_BY_PK, CONVERSATION_SUBSCRIPTION_BY_PK,
{ {
variables: { conversationId: selectedConversation }, variables: {conversationId: selectedConversation},
} }
); );
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false);
const [markConversationRead] = useMutation( const [markConversationRead] = useMutation(
MARK_MESSAGES_AS_READ_BY_CONVERSATION, MARK_MESSAGES_AS_READ_BY_CONVERSATION,
{ {
variables: { conversationId: selectedConversation }, variables: {conversationId: selectedConversation},
refetchQueries: ["UNREAD_CONVERSATION_COUNT"], refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
update(cache) { update(cache) {
cache.modify({ cache.modify({
id: cache.identify({ id: cache.identify({
__typename: "conversations", __typename: "conversations",
id: selectedConversation, id: selectedConversation,
}), }),
fields: { fields: {
messages_aggregate(cached) { messages_aggregate(cached) {
return { aggregate: { count: 0 } }; return {aggregate: {count: 0}};
},
},
});
}, },
}, }
}); );
},
}
);
const unreadCount = const unreadCount =
data && data &&
data.messages && data.messages &&
data.messages.reduce((acc, val) => { data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc; return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0); }, 0);
const handleMarkConversationAsRead = async () => { const handleMarkConversationAsRead = async () => {
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) { if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true); setMarkingAsReadInProgress(true);
await markConversationRead({}); await markConversationRead({});
await axios.post("/sms/markConversationRead", { await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation, conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid, imexshopid: bodyshop.imexshopid,
}); });
setMarkingAsReadInProgress(false); setMarkingAsReadInProgress(false);
} }
}; };
return ( return (
<ChatConversationComponent <ChatConversationComponent
subState={[loading || convoLoading, error || convoError]} subState={[loading || convoLoading, error || convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}} conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []} messages={data ? data.messages : []}
handleMarkConversationAsRead={handleMarkConversationAsRead} handleMarkConversationAsRead={handleMarkConversationAsRead}
/> />
); );
} }

View File

@@ -1,67 +1,68 @@
import { PlusOutlined } from "@ant-design/icons"; import {PlusOutlined} from "@ant-design/icons";
import { useMutation } from "@apollo/client"; import {useMutation} from "@apollo/client";
import { Input, notification, Spin, Tag, Tooltip } from "antd"; import {Input, notification, Spin, Tag, Tooltip} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { UPDATE_CONVERSATION_LABEL } from "../../graphql/conversations.queries"; import {UPDATE_CONVERSATION_LABEL} from "../../graphql/conversations.queries";
export default function ChatLabel({ conversation }) {
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const { t } = useTranslation(); export default function ChatLabel({conversation}) {
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL); const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label);
const handleSave = async () => { const {t} = useTranslation();
setLoading(true); const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
try {
const response = await updateLabel({ const handleSave = async () => {
variables: { id: conversation.id, label: value }, setLoading(true);
}); try {
if (response.errors) { const response = await updateLabel({
notification["error"]({ variables: {id: conversation.id, label: value},
message: t("messages.errors.updatinglabel", { });
error: JSON.stringify(response.errors), if (response.errors) {
}), notification["error"]({
}); message: t("messages.errors.updatinglabel", {
} else { error: JSON.stringify(response.errors),
setEditing(false); }),
} });
} catch (error) { } else {
notification["error"]({ setEditing(false);
message: t("messages.errors.updatinglabel", { }
error: JSON.stringify(error), } catch (error) {
}), notification["error"]({
}); message: t("messages.errors.updatinglabel", {
} finally { error: JSON.stringify(error),
setLoading(false); }),
});
} finally {
setLoading(false);
}
};
if (editing) {
return (
<div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleSave}
allowClear
/>
{loading && <Spin size="small"/>}
</div>
);
} else {
return conversation.label && conversation.label.trim() !== "" ? (
<Tag style={{cursor: "pointer"}} onClick={() => setEditing(true)}>
{conversation.label}
</Tag>
) : (
<Tooltip title={t("messaging.labels.addlabel")}>
<PlusOutlined
style={{cursor: "pointer"}}
onClick={() => setEditing(true)}
/>
</Tooltip>
);
} }
};
if (editing) {
return (
<div>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={handleSave}
allowClear
/>
{loading && <Spin size="small" />}
</div>
);
} else {
return conversation.label && conversation.label.trim() !== "" ? (
<Tag style={{ cursor: "pointer" }} onClick={() => setEditing(true)}>
{conversation.label}
</Tag>
) : (
<Tooltip title={t("messaging.labels.addlabel")}>
<PlusOutlined
style={{ cursor: "pointer" }}
onClick={() => setEditing(true)}
/>
</Tooltip>
);
}
} }

View File

@@ -1,99 +1,100 @@
import { PictureFilled } from "@ant-design/icons"; import {PictureFilled} from "@ant-design/icons";
import { useQuery } from "@apollo/client"; import {useQuery} from "@apollo/client";
import { Badge, Popover } from "antd"; import {Badge, Popover} from "antd";
import React, { useEffect, useState } from "react"; import React, {useEffect, useState} from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { GET_DOCUMENTS_BY_JOB } from "../../graphql/documents.queries"; import {GET_DOCUMENTS_BY_JOB} from "../../graphql/documents.queries";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component"; import JobDocumentsGalleryExternal from "../jobs-documents-gallery/jobs-documents-gallery.external.component";
import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component"; import JobDocumentsLocalGalleryExternal
from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import LoadingSpinner from "../loading-spinner/loading-spinner.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
}); });
export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector); export default connect(mapStateToProps, mapDispatchToProps)(ChatMediaSelector);
export function ChatMediaSelector({ export function ChatMediaSelector({
bodyshop, bodyshop,
selectedMedia, selectedMedia,
setSelectedMedia, setSelectedMedia,
conversation, conversation,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [visible, setVisible] = useState(false); const [open, setOpen] = useState(false);
const { loading, error, data } = useQuery(GET_DOCUMENTS_BY_JOB, { const {loading, error, data} = useQuery(GET_DOCUMENTS_BY_JOB, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
variables: { variables: {
jobId: jobId:
conversation.job_conversations[0] && conversation.job_conversations[0] &&
conversation.job_conversations[0].jobid, conversation.job_conversations[0].jobid,
}, },
skip: skip:
!visible || !open ||
!conversation.job_conversations || !conversation.job_conversations ||
conversation.job_conversations.length === 0, conversation.job_conversations.length === 0,
}); });
const handleVisibleChange = (visible) => { const handleVisibleChange = (change) => {
setVisible(visible); setOpen(change);
}; };
useEffect(() => { useEffect(() => {
setSelectedMedia([]); setSelectedMedia([]);
}, [setSelectedMedia, conversation]); }, [setSelectedMedia, conversation]);
const content = ( const content = (
<div> <div>
{loading && <LoadingSpinner />} {loading && <LoadingSpinner/>}
{error && <AlertComponent message={error.message} type="error" />} {error && <AlertComponent message={error.message} type="error"/>}
{selectedMedia.filter((s) => s.isSelected).length >= 10 ? ( {selectedMedia.filter((s) => s.isSelected).length >= 10 ? (
<div style={{ color: "red" }}>{t("messaging.labels.maxtenimages")}</div> <div style={{color: "red"}}>{t("messaging.labels.maxtenimages")}</div>
) : null} ) : null}
{!bodyshop.uselocalmediaserver && data && ( {!bodyshop.uselocalmediaserver && data && (
<JobDocumentsGalleryExternal <JobDocumentsGalleryExternal
data={data ? data.documents : []} data={data ? data.documents : []}
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
/> />
)} )}
{bodyshop.uselocalmediaserver && visible && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={ jobId={
conversation.job_conversations[0] && conversation.job_conversations[0] &&
conversation.job_conversations[0].jobid conversation.job_conversations[0].jobid
} }
/> />
)} )}
</div> </div>
); );
return ( return (
<Popover <Popover
content={ content={
conversation.job_conversations.length === 0 ? ( conversation.job_conversations.length === 0 ? (
<div>{t("messaging.errors.noattachedjobs")}</div> <div>{t("messaging.errors.noattachedjobs")}</div>
) : ( ) : (
content content
) )
} }
title={t("messaging.labels.selectmedia")} title={t("messaging.labels.selectmedia")}
trigger="click" trigger="click"
visible={visible} open={open}
onVisibleChange={handleVisibleChange} onOpenChange={handleVisibleChange}
> >
<Badge count={selectedMedia.filter((s) => s.isSelected).length}> <Badge count={selectedMedia.filter((s) => s.isSelected).length}>
<PictureFilled style={{ margin: "0 .5rem" }} /> <PictureFilled style={{margin: "0 .5rem"}}/>
</Badge> </Badge>
</Popover> </Popover>
); );
} }

View File

@@ -1,118 +1,113 @@
import Icon from "@ant-design/icons"; import Icon from "@ant-design/icons";
import { Tooltip } from "antd"; import {Tooltip} from "antd";
import i18n from "i18next"; import i18n from "i18next";
import moment from "moment"; import dayjs from "../../utils/day";
import React, { useEffect, useRef } from "react"; import React, {useEffect, useRef} from "react";
import { MdDone, MdDoneAll } from "react-icons/md"; import {MdDone, MdDoneAll} from "react-icons/md";
import { import {AutoSizer, CellMeasurer, CellMeasurerCache, List,} from "react-virtualized";
AutoSizer, import {DateTimeFormatter} from "../../utils/DateFormatter";
CellMeasurer,
CellMeasurerCache,
List,
} from "react-virtualized";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import "./chat-message-list.styles.scss"; import "./chat-message-list.styles.scss";
export default function ChatMessageListComponent({ messages }) { export default function ChatMessageListComponent({messages}) {
const virtualizedListRef = useRef(null); const virtualizedListRef = useRef(null);
const _cache = new CellMeasurerCache({ const _cache = new CellMeasurerCache({
fixedWidth: true, fixedWidth: true,
// minHeight: 50, // minHeight: 50,
defaultHeight: 100, defaultHeight: 100,
}); });
const scrollToBottom = (renderedrows) => { const scrollToBottom = (renderedrows) => {
//console.log("Scrolling to", messages.length); //console.log("Scrolling to", messages.length);
// !!virtualizedListRef.current && // !!virtualizedListRef.current &&
// virtualizedListRef.current.scrollToRow(messages.length); // virtualizedListRef.current.scrollToRow(messages.length);
// Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179 // Outstanding isue on virtualization: https://github.com/bvaughn/react-virtualized/issues/1179
//Scrolling does not work on this version of React. //Scrolling does not work on this version of React.
}; };
useEffect(scrollToBottom, [messages]); useEffect(scrollToBottom, [messages]);
const _rowRenderer = ({index, key, parent, style}) => {
return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
{({measure, registerChild}) => (
<div
ref={registerChild}
onLoad={measure}
style={style}
className={`${
messages[index].isoutbound ? "mine messages" : "yours messages"
}`}
>
<div className="message msgmargin">
{MessageRender(messages[index])}
{StatusRender(messages[index].status)}
</div>
{messages[index].isoutbound && (
<div style={{fontSize: 10}}>
{i18n.t("messaging.labels.sentby", {
by: messages[index].userid,
time: dayjs(messages[index].created_at).format(
"MM/DD/YYYY @ hh:mm a"
),
})}
</div>
)}
</div>
)}
</CellMeasurer>
);
};
const _rowRenderer = ({ index, key, parent, style }) => {
return ( return (
<CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}> <div className="chat">
{({ measure, registerChild }) => ( <AutoSizer>
<div {({height, width}) => (
ref={registerChild} <List
onLoad={measure} ref={virtualizedListRef}
style={style} width={width}
className={`${ height={height}
messages[index].isoutbound ? "mine messages" : "yours messages" rowHeight={_cache.rowHeight}
}`} rowRenderer={_rowRenderer}
> rowCount={messages.length}
<div className="message msgmargin"> overscanRowCount={10}
{MessageRender(messages[index])} estimatedRowSize={150}
{StatusRender(messages[index].status)} scrollToIndex={messages.length}
</div> />
{messages[index].isoutbound && ( )}
<div style={{ fontSize: 10 }}> </AutoSizer>
{i18n.t("messaging.labels.sentby", { </div>
by: messages[index].userid,
time: moment(messages[index].created_at).format(
"MM/DD/YYYY @ hh:mm a"
),
})}
</div>
)}
</div>
)}
</CellMeasurer>
); );
};
return (
<div className="chat">
<AutoSizer>
{({ height, width }) => (
<List
ref={virtualizedListRef}
width={width}
height={height}
rowHeight={_cache.rowHeight}
rowRenderer={_rowRenderer}
rowCount={messages.length}
overscanRowCount={10}
estimatedRowSize={150}
scrollToIndex={messages.length}
/>
)}
</AutoSizer>
</div>
);
} }
const MessageRender = (message) => { const MessageRender = (message) => {
return ( return (
<Tooltip title={DateTimeFormatter({ children: message.created_at })}> <Tooltip title={DateTimeFormatter({children: message.created_at})}>
<div> <div>
{message.image_path && {message.image_path &&
message.image_path.map((i, idx) => ( message.image_path.map((i, idx) => (
<div <div
key={idx} key={idx}
style={{ display: "flex", justifyContent: "center" }} style={{display: "flex", justifyContent: "center"}}
> >
<a href={i} target="__blank"> <a href={i} target="__blank">
<img alt="Received" className="message-img" src={i} /> <img alt="Received" className="message-img" src={i}/>
</a> </a>
</div>
))}
<div>{message.text}</div>
</div> </div>
))} </Tooltip>
<div>{message.text}</div> );
</div>
</Tooltip>
);
}; };
const StatusRender = (status) => { const StatusRender = (status) => {
switch (status) { switch (status) {
case "sent": case "sent":
return <Icon component={MdDone} className="message-icon" />; return <Icon component={MdDone} className="message-icon"/>;
case "delivered": case "delivered":
return <Icon component={MdDoneAll} className="message-icon" />; return <Icon component={MdDoneAll} className="message-icon"/>;
default: default:
return null; return null;
} }
}; };

View File

@@ -44,6 +44,7 @@
.yours { .yours {
align-items: flex-start; align-items: flex-start;
} }
.msgmargin { .msgmargin {
margin-top: 0.1rem; margin-top: 0.1rem;
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
@@ -66,6 +67,7 @@
background: #eee; background: #eee;
border-bottom-right-radius: 15px; border-bottom-right-radius: 15px;
} }
.yours .message.last:after { .yours .message.last:after {
content: ""; content: "";
position: absolute; position: absolute;

View File

@@ -1,57 +1,55 @@
import { PlusCircleFilled } from "@ant-design/icons"; import {PlusCircleFilled} from "@ant-design/icons";
import { Button, Form, Popover } from "antd"; import {Button, Form, Popover} from "antd";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions"; import {openChatByPhone} from "../../redux/messaging/messaging.actions";
import PhoneFormItem, { import PhoneFormItem, {PhoneItemFormatterValidation,} from "../form-items-formatted/phone-form-item.component";
PhoneItemFormatterValidation,
} from "../form-items-formatted/phone-form-item.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
}); });
export function ChatNewConversation({ openChatByPhone }) { export function ChatNewConversation({openChatByPhone}) {
const { t } = useTranslation(); const {t} = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const handleFinish = (values) => { const handleFinish = (values) => {
openChatByPhone({ phone_num: values.phoneNumber }); openChatByPhone({phone_num: values.phoneNumber});
form.resetFields(); form.resetFields();
}; };
const popContent = ( const popContent = (
<div> <div>
<Form form={form} onFinish={handleFinish}> <Form form={form} onFinish={handleFinish}>
<Form.Item <Form.Item
label={t("messaging.labels.phonenumber")} label={t("messaging.labels.phonenumber")}
name="phoneNumber" name="phoneNumber"
rules={[ rules={[
({ getFieldValue }) => ({getFieldValue}) =>
PhoneItemFormatterValidation(getFieldValue, "phoneNumber"), PhoneItemFormatterValidation(getFieldValue, "phoneNumber"),
]} ]}
> >
<PhoneFormItem /> <PhoneFormItem/>
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
{t("messaging.actions.new")} {t("messaging.actions.new")}
</Button> </Button>
</Form> </Form>
</div> </div>
); );
return ( return (
<Popover trigger="click" content={popContent}> <Popover trigger="click" content={popContent}>
<PlusCircleFilled /> <PlusCircleFilled/>
</Popover> </Popover>
); );
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(ChatNewConversation); )(ChatNewConversation);

View File

@@ -1,52 +1,54 @@
import { notification } from "antd"; import {notification} from "antd";
import parsePhoneNumber from "libphonenumber-js"; import parsePhoneNumber from "libphonenumber-js";
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import {useTranslation} from "react-i18next";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions"; import {openChatByPhone} from "../../redux/messaging/messaging.actions";
import PhoneNumberFormatter from "../../utils/PhoneFormatter"; import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors"; import {searchingForConversation} from "../../redux/messaging/messaging.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
searchingForConversation: searchingForConversation, searchingForConversation: searchingForConversation,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone)), openChatByPhone: (phone) => dispatch(openChatByPhone(phone)),
}); });
export function ChatOpenButton({ export function ChatOpenButton({
bodyshop, bodyshop,
searchingForConversation, searchingForConversation,
phone, phone,
jobid, jobid,
openChatByPhone, openChatByPhone,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
if (!phone) return <></>; if (!phone) return <></>;
if (!bodyshop.messagingservicesid) if (!bodyshop.messagingservicesid)
return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>; return <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>;
return ( return (
<a <a
href="# " href="# "
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const p = parsePhoneNumber(phone, "CA"); const p = parsePhoneNumber(phone, "CA");
if (searchingForConversation) return; //This is to prevent finding the same thing twice. if (searchingForConversation) return; //This is to prevent finding the same thing twice.
if (p && p.isValid()) { if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid: jobid }); openChatByPhone({phone_num: p.formatInternational(), jobid: jobid});
} else { } else {
notification["error"]({ message: t("messaging.error.invalidphone") }); notification["error"]({message: t("messaging.error.invalidphone")});
} }
}} }}
> >
<PhoneNumberFormatter>{phone}</PhoneNumberFormatter> <PhoneNumberFormatter>{phone}</PhoneNumberFormatter>
</a> </a>
); );
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatOpenButton); export default connect(mapStateToProps, mapDispatchToProps)(ChatOpenButton);

View File

@@ -1,24 +1,13 @@
import { import {InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined,} from "@ant-design/icons";
InfoCircleOutlined, import {useLazyQuery, useQuery} from "@apollo/client";
MessageOutlined, import {Badge, Card, Col, Row, Space, Tag, Tooltip, Typography} from "antd";
ShrinkOutlined, import React, {useCallback, useEffect, useState} from "react";
SyncOutlined, import {useTranslation} from "react-i18next";
} from "@ant-design/icons"; import {connect} from "react-redux";
import { useLazyQuery, useQuery } from "@apollo/client"; import {createStructuredSelector} from "reselect";
import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd"; import {CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT,} from "../../graphql/conversations.queries";
import React, { useCallback, useEffect, useState } from "react"; import {toggleChatVisible} from "../../redux/messaging/messaging.actions";
import { useTranslation } from "react-i18next"; import {selectChatVisible, selectSelectedConversation,} from "../../redux/messaging/messaging.selectors";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import {
CONVERSATION_LIST_QUERY,
UNREAD_CONVERSATION_COUNT,
} from "../../graphql/conversations.queries";
import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import {
selectChatVisible,
selectSelectedConversation,
} from "../../redux/messaging/messaging.selectors";
import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component"; import ChatConversationListComponent from "../chat-conversation-list/chat-conversation-list.component";
import ChatConversationContainer from "../chat-conversation/chat-conversation.container"; import ChatConversationContainer from "../chat-conversation/chat-conversation.container";
import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component"; import ChatNewConversation from "../chat-new-conversation/chat-new-conversation.component";
@@ -26,118 +15,119 @@ import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import "./chat-popup.styles.scss"; import "./chat-popup.styles.scss";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
selectedConversation: selectSelectedConversation, selectedConversation: selectSelectedConversation,
chatVisible: selectChatVisible, chatVisible: selectChatVisible,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
toggleChatVisible: () => dispatch(toggleChatVisible()), toggleChatVisible: () => dispatch(toggleChatVisible()),
}); });
export function ChatPopupComponent({ export function ChatPopupComponent({
chatVisible, chatVisible,
selectedConversation, selectedConversation,
toggleChatVisible, toggleChatVisible,
}) { }) {
const { t } = useTranslation(); const {t} = useTranslation();
const [pollInterval, setpollInterval] = useState(0); const [pollInterval, setpollInterval] = useState(0);
const { data: unreadData } = useQuery(UNREAD_CONVERSATION_COUNT, { const {data: unreadData} = useQuery(UNREAD_CONVERSATION_COUNT, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
...(pollInterval > 0 ? { pollInterval } : {}), ...(pollInterval > 0 ? {pollInterval} : {}),
});
const [getConversations, { loading, data, refetch, fetchMore }] =
useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? { pollInterval } : {}),
}); });
const fcmToken = sessionStorage.getItem("fcmtoken"); const [getConversations, {loading, data, refetch, fetchMore}] =
useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
skip: !chatVisible,
...(pollInterval > 0 ? {pollInterval} : {}),
});
useEffect(() => { const fcmToken = sessionStorage.getItem("fcmtoken");
if (fcmToken) {
setpollInterval(0);
} else {
setpollInterval(60000);
}
}, [fcmToken]);
useEffect(() => { useEffect(() => {
if (chatVisible) if (fcmToken) {
getConversations({ setpollInterval(0);
variables: { } else {
offset: 0, setpollInterval(60000);
}, }
}); }, [fcmToken]);
}, [chatVisible, getConversations]);
const loadMoreConversations = useCallback(() => { useEffect(() => {
if (data) if (chatVisible)
fetchMore({ getConversations({
variables: { variables: {
offset: data.conversations.length, offset: 0,
}, },
}); });
}, [data, fetchMore]); }, [chatVisible, getConversations]);
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0; const loadMoreConversations = useCallback(() => {
if (data)
fetchMore({
variables: {
offset: data.conversations.length,
},
});
}, [data, fetchMore]);
return ( const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
<Badge count={unreadCount}>
<Card size="small">
{chatVisible ? (
<div className="chat-popup">
<Space align="center">
<Typography.Title level={4}>
{t("messaging.labels.messaging")}
</Typography.Title>
<ChatNewConversation />
<Tooltip title={t("messaging.labels.recentonly")}>
<InfoCircleOutlined />
</Tooltip>
<SyncOutlined
style={{ cursor: "pointer" }}
onClick={() => refetch()}
/>
{pollInterval > 0 && (
<Tag color="yellow">{t("messaging.labels.nopush")}</Tag>
)}
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{ position: "absolute", right: ".5rem", top: ".5rem" }}
/>
<Row gutter={[8, 8]} className="chat-popup-content"> return (
<Col span={8}> <Badge count={unreadCount}>
{loading ? ( <Card size="small">
<LoadingSpinner /> {chatVisible ? (
<div className="chat-popup">
<Space align="center">
<Typography.Title level={4}>
{t("messaging.labels.messaging")}
</Typography.Title>
<ChatNewConversation/>
<Tooltip title={t("messaging.labels.recentonly")}>
<InfoCircleOutlined/>
</Tooltip>
<SyncOutlined
style={{cursor: "pointer"}}
onClick={() => refetch()}
/>
{pollInterval > 0 && (
<Tag color="yellow">{t("messaging.labels.nopush")}</Tag>
)}
</Space>
<ShrinkOutlined
onClick={() => toggleChatVisible()}
style={{position: "absolute", right: ".5rem", top: ".5rem"}}
/>
<Row gutter={[8, 8]} className="chat-popup-content">
<Col span={8}>
{loading ? (
<LoadingSpinner/>
) : (
<ChatConversationListComponent
conversationList={data ? data.conversations : []}
loadMoreConversations={loadMoreConversations}
/>
)}
</Col>
<Col span={16}>
{selectedConversation ? <ChatConversationContainer/> : null}
</Col>
</Row>
</div>
) : ( ) : (
<ChatConversationListComponent <div
conversationList={data ? data.conversations : []} onClick={() => toggleChatVisible()}
loadMoreConversations={loadMoreConversations} style={{cursor: "pointer"}}
/> >
<MessageOutlined className="chat-popup-info-icon"/>
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)} )}
</Col> </Card>
<Col span={16}> </Badge>
{selectedConversation ? <ChatConversationContainer /> : null} );
</Col>
</Row>
</div>
) : (
<div
onClick={() => toggleChatVisible()}
style={{ cursor: "pointer" }}
>
<MessageOutlined className="chat-popup-info-icon" />
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)}
</Card>
</Badge>
);
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatPopupComponent); export default connect(mapStateToProps, mapDispatchToProps)(ChatPopupComponent);

View File

@@ -13,6 +13,7 @@
height: 100%; height: 100%;
} }
} }
.chat-popup-info-icon { .chat-popup-info-icon {
margin-right: 5px; margin-right: 5px;
} }

View File

@@ -1,40 +1,38 @@
import { PlusCircleOutlined } from "@ant-design/icons"; import {PlusCircleOutlined} from "@ant-design/icons";
import { Dropdown, Menu } from "antd"; import {Dropdown} from "antd";
import React from "react"; import React from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { setMessage } from "../../redux/messaging/messaging.actions"; import {setMessage} from "../../redux/messaging/messaging.actions";
import { selectBodyshop } from "../../redux/user/user.selectors"; import {selectBodyshop} from "../../redux/user/user.selectors";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
//setUserLanguage: language => dispatch(setUserLanguage(language)) //setUserLanguage: language => dispatch(setUserLanguage(language))
setMessage: (message) => dispatch(setMessage(message)), setMessage: (message) => dispatch(setMessage(message)),
}); });
export function ChatPresetsComponent({ bodyshop, setMessage, className }) { export function ChatPresetsComponent({bodyshop, setMessage, className}) {
const menu = (
<Menu>
{bodyshop.md_messaging_presets.map((i, idx) => (
<Menu.Item onClick={() => setMessage(i.text)} key={idx}>
{i.label}
</Menu.Item>
))}
</Menu>
);
return ( const items = bodyshop.md_messaging_presets.map((i, idx) => ({
<div className={className}> key: idx,
<Dropdown trigger={["click"]} overlay={menu}> label: (i.label),
<PlusCircleOutlined /> onClick: () => setMessage(i.text),
</Dropdown> }));
</div>
); return (
<div className={className}>
<Dropdown trigger={["click"]} menu={{items}}>
<PlusCircleOutlined/>
</Dropdown>
</div>
);
} }
export default connect( export default connect(
mapStateToProps, mapStateToProps,
mapDispatchToProps mapDispatchToProps
)(ChatPresetsComponent); )(ChatPresetsComponent);

View File

@@ -1,59 +1,46 @@
import { MailOutlined, PrinterOutlined } from "@ant-design/icons"; import {MailOutlined, PrinterOutlined} from "@ant-design/icons";
import { Space, Spin } from "antd"; import {Space, Spin} from "antd";
import React, { useState } from "react"; import React, {useState} from "react";
import { connect } from "react-redux"; import {connect} from "react-redux";
import { createStructuredSelector } from "reselect"; import {createStructuredSelector} from "reselect";
import { setEmailOptions } from "../../redux/email/email.actions"; import {setEmailOptions} from "../../redux/email/email.actions";
import { GenerateDocument } from "../../utils/RenderTemplate"; import {GenerateDocument} from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import {TemplateList} from "../../utils/TemplateConstants";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
setEmailOptions: (e) => dispatch(setEmailOptions(e)), setEmailOptions: (e) => dispatch(setEmailOptions(e)),
}); });
export function ChatPrintButton({ conversation }) { export function ChatPrintButton({conversation}) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
return ( const generateDocument = (type) => {
<Space wrap> setLoading(true);
<PrinterOutlined GenerateDocument(
onClick={() => {
setLoading(true);
GenerateDocument(
{ {
name: TemplateList("messaging").conversation_list.key, name: TemplateList("messaging").conversation_list.key,
variables: { id: conversation.id }, variables: {id: conversation.id},
}, },
{ {
subject: TemplateList("messaging").conversation_list.subject, subject: TemplateList("messaging").conversation_list.subject,
}, },
"p", type,
conversation.id conversation.id
); ).catch(e => {
setLoading(false); console.warn('Something went wrong generating a document.');
}} });
/> setLoading(false);
<MailOutlined }
onClick={() => {
setLoading(true); return (
GenerateDocument( <Space wrap>
{ <PrinterOutlined onClick={() => generateDocument('p')}/>
name: TemplateList("messaging").conversation_list.key, <MailOutlined onClick={() => generateDocument('e')}/>
variables: { id: conversation.id }, {loading && <Spin/>}
}, </Space>
{ );
subject: TemplateList("messaging").conversation_list.subject,
},
"e",
conversation.id
);
setLoading(false);
}}
/>
{loading && <Spin />}
</Space>
);
} }
export default connect(mapStateToProps, mapDispatchToProps)(ChatPrintButton); export default connect(mapStateToProps, mapDispatchToProps)(ChatPrintButton);

Some files were not shown because too many files have changed in this diff Show More