Reformat all project files to use the prettier config file.

This commit is contained in:
Patrick Fic
2024-03-27 15:35:07 -07:00
parent b161530381
commit e1df64d592
873 changed files with 111387 additions and 125473 deletions

View File

@@ -1,16 +1,18 @@
exports.default = { const config = {
printWidth: 120, printWidth: 120,
useTabs: false, useTabs: false,
tabWidth: 2, tabWidth: 2,
trailingComma: 'es5', trailingComma: "none",
semi: true, semi: true,
singleQuote: false, singleQuote: false,
bracketSpacing: true, bracketSpacing: true,
arrowParens: 'always', arrowParens: "always",
jsxSingleQuote: false, jsxSingleQuote: false,
bracketSameLine: false, bracketSameLine: false,
endOfLine: 'lf', endOfLine: "lf",
importOrder: ['^@core/(.*)$', '^@server/(.*)$', '^@ui/(.*)$', '^[./]'], importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"],
importOrderSeparation: true, importOrderSeparation: true,
importOrderSortSpecifiers: true, importOrderSortSpecifiers: true
}; };
module.exports = config;

View File

@@ -567,4 +567,4 @@
"description": "Exempt" "description": "Exempt"
} }
] ]
} }

View File

@@ -1,20 +1,20 @@
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "IO Test API", name: "IO Test API",
cwd: "./io", cwd: "./io",
script: "./server.js", script: "./server.js",
env: { env: {
NODE_ENV: "test", NODE_ENV: "test"
}, }
}, },
{ {
name: "Bitbucket Webhook", name: "Bitbucket Webhook",
script: "./webhook/index.js", script: "./webhook/index.js",
env: { env: {
NODE_ENV: "production", NODE_ENV: "production"
}, }
}, }
], ]
}; };

View File

@@ -1,10 +1,10 @@
// craco.config.js // craco.config.js
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
const CracoLessPlugin = require("craco-less"); const CracoLessPlugin = require("craco-less");
const {convertLegacyToken} = require('@ant-design/compatible/lib'); const { convertLegacyToken } = require("@ant-design/compatible/lib");
const {theme} = require('antd/lib'); const { theme } = require("antd/lib");
const {defaultAlgorithm, defaultSeed} = theme; const { defaultAlgorithm, defaultSeed } = theme;
const mapToken = defaultAlgorithm(defaultSeed); const mapToken = defaultAlgorithm(defaultSeed);
const v4Token = convertLegacyToken(mapToken); const v4Token = convertLegacyToken(mapToken);
@@ -12,43 +12,42 @@ const v4Token = convertLegacyToken(mapToken);
// TODO, At the moment we are using less in the Dashboard. Once we remove this we can remove the less processor entirely. // TODO, At the moment we are using less in the Dashboard. Once we remove this we can remove the less processor entirely.
module.exports = { module.exports = {
plugins: [ plugins: [
{
{ plugin: CracoLessPlugin,
plugin: CracoLessPlugin, options: {
options: { lessLoaderOptions: {
lessLoaderOptions: { lessOptions: {
lessOptions: { modifyVars: { ...v4Token },
modifyVars: {...v4Token}, javascriptEnabled: true
javascriptEnabled: true, }
}, }
}, }
}, }
],
webpack: {
configure: (webpackConfig) => {
return {
...webpackConfig,
// Required for Dev Server
devServer: {
...webpackConfig.devServer,
allowedHosts: "all"
}, },
], optimization: {
webpack: { ...webpackConfig.optimization,
configure: (webpackConfig) => { // Workaround for CircleCI bug caused by the number of CPUs shown
return { // https://github.com/facebook/create-react-app/issues/8320
...webpackConfig, minimizer: webpackConfig.optimization.minimizer.map((item) => {
// Required for Dev Server if (item instanceof TerserPlugin) {
devServer: { item.options.parallel = 2;
...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"
}; };

View File

@@ -1,17 +1,17 @@
const {defineConfig} = require('cypress') const { defineConfig } = require("cypress");
module.exports = defineConfig({ module.exports = defineConfig({
experimentalStudio: true, experimentalStudio: true,
env: { env: {
FIREBASE_USERNAME: 'cypress@imex.test', FIREBASE_USERNAME: "cypress@imex.test",
FIREBASE_PASSWORD: 'cypress', 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);
}, },
e2e: { baseUrl: "http://localhost:3000"
// 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,24 +1,19 @@
/// <reference types="Cypress" /> /// <reference types="Cypress" />
const {FIREBASE_USERNAME, FIREBASE_PASSWORcD} = Cypress.env(); const { FIREBASE_USERNAME, FIREBASE_PASSWORcD } = Cypress.env();
describe("Renders the General Page", () => { describe("Renders the General Page", () => {
beforeEach(() => { beforeEach(() => {
cy.visit("/"); cy.visit("/");
}); });
it("Renders Correctly", () => { it("Renders Correctly", () => {});
}); it("Has the Slogan", () => {
it("Has the Slogan", () => { cy.findByText("A whole x22new kind of shop management system.").should("exist");
cy.findByText("A whole x22new kind of shop management system.").should( /* ==== Generated with Cypress Studio ==== */
"exist" cy.get(".ant-menu-item-active > .ant-menu-title-content > .header0-item-block").click();
); cy.get("#email").clear();
/* ==== Generated with Cypress Studio ==== */ cy.get("#email").type("patrick@imex.dev");
cy.get( cy.get("#password").clear();
".ant-menu-item-active > .ant-menu-title-content > .header0-item-block" cy.get("#password").type("patrick123{enter}");
).click(); cy.get(".ant-form > .ant-btn").click();
cy.get("#email").clear(); /* ==== End Cypress Studio ==== */
cy.get("#email").type("patrick@imex.dev"); });
cy.get("#password").clear();
cy.get("#password").type("patrick123{enter}");
cy.get(".ant-form > .ant-btn").click();
/* ==== End Cypress Studio ==== */
});
}); });

View File

@@ -11,133 +11,114 @@
// please read our getting started guide: // please read our getting started guide:
// https://on.cypress.io/introduction-to-cypress // https://on.cypress.io/introduction-to-cypress
describe('example to-do app', () => { describe("example to-do app", () => {
beforeEach(() => {
// Cypress starts out with a blank slate for each test
// so we must tell it to visit our website with the `cy.visit()` command.
// Since we want to visit the same URL at the start of all our tests,
// we include it in our beforeEach function so that it runs before each test
cy.visit("https://example.cypress.io/todo");
});
it("displays two todo items by default", () => {
// We use the `cy.get()` command to get all elements that match the selector.
// Then, we use `should` to assert that there are two matched items,
// which are the two default items.
cy.get(".todo-list li").should("have.length", 2);
// We can go even further and check that the default todos each contain
// the correct text. We use the `first` and `last` functions
// to get just the first and last matched elements individually,
// and then perform an assertion with `should`.
cy.get(".todo-list li").first().should("have.text", "Pay electric bill");
cy.get(".todo-list li").last().should("have.text", "Walk the dog");
});
it("can add new todo items", () => {
// We'll store our item text in a variable so we can reuse it
const newItem = "Feed the cat";
// Let's get the input element and use the `type` command to
// input our new list item. After typing the content of our item,
// we need to type the enter key as well in order to submit the input.
// This input has a data-test attribute so we'll use that to select the
// element in accordance with best practices:
// https://on.cypress.io/selecting-elements
cy.get("[data-test=new-todo]").type(`${newItem}{enter}`);
// Now that we've typed our new item, let's check that it actually was added to the list.
// Since it's the newest item, it should exist as the last element in the list.
// In addition, with the two default items, we should have a total of 3 elements in the list.
// Since assertions yield the element that was asserted on,
// we can chain both of these assertions together into a single statement.
cy.get(".todo-list li").should("have.length", 3).last().should("have.text", newItem);
});
it("can check off an item as completed", () => {
// In addition to using the `get` command to get an element by selector,
// we can also use the `contains` command to get an element by its contents.
// However, this will yield the <label>, which is lowest-level element that contains the text.
// In order to check the item, we'll find the <input> element for this <label>
// by traversing up the dom to the parent element. From there, we can `find`
// the child checkbox <input> element and use the `check` command to check it.
cy.contains("Pay electric bill").parent().find("input[type=checkbox]").check();
// Now that we've checked the button, we can go ahead and make sure
// that the list element is now marked as completed.
// Again we'll use `contains` to find the <label> element and then use the `parents` command
// to traverse multiple levels up the dom until we find the corresponding <li> element.
// Once we get that element, we can assert that it has the completed class.
cy.contains("Pay electric bill").parents("li").should("have.class", "completed");
});
context("with a checked task", () => {
beforeEach(() => { beforeEach(() => {
// Cypress starts out with a blank slate for each test // We'll take the command we used above to check off an element
// so we must tell it to visit our website with the `cy.visit()` command. // Since we want to perform multiple tests that start with checking
// Since we want to visit the same URL at the start of all our tests, // one element, we put it in the beforeEach hook
// we include it in our beforeEach function so that it runs before each test // so that it runs at the start of every test.
cy.visit('https://example.cypress.io/todo') cy.contains("Pay electric bill").parent().find("input[type=checkbox]").check();
}) });
it('displays two todo items by default', () => { it("can filter for uncompleted tasks", () => {
// We use the `cy.get()` command to get all elements that match the selector. // We'll click on the "active" button in order to
// Then, we use `should` to assert that there are two matched items, // display only incomplete items
// which are the two default items. cy.contains("Active").click();
cy.get('.todo-list li').should('have.length', 2)
// We can go even further and check that the default todos each contain // After filtering, we can assert that there is only the one
// the correct text. We use the `first` and `last` functions // incomplete item in the list.
// to get just the first and last matched elements individually, cy.get(".todo-list li").should("have.length", 1).first().should("have.text", "Walk the dog");
// and then perform an assertion with `should`.
cy.get('.todo-list li').first().should('have.text', 'Pay electric bill')
cy.get('.todo-list li').last().should('have.text', 'Walk the dog')
})
it('can add new todo items', () => { // For good measure, let's also assert that the task we checked off
// We'll store our item text in a variable so we can reuse it // does not exist on the page.
const newItem = 'Feed the cat' cy.contains("Pay electric bill").should("not.exist");
});
// Let's get the input element and use the `type` command to it("can filter for completed tasks", () => {
// input our new list item. After typing the content of our item, // We can perform similar steps as the test above to ensure
// we need to type the enter key as well in order to submit the input. // that only completed tasks are shown
// This input has a data-test attribute so we'll use that to select the cy.contains("Completed").click();
// element in accordance with best practices:
// https://on.cypress.io/selecting-elements
cy.get('[data-test=new-todo]').type(`${newItem}{enter}`)
// Now that we've typed our new item, let's check that it actually was added to the list. cy.get(".todo-list li").should("have.length", 1).first().should("have.text", "Pay electric bill");
// Since it's the newest item, it should exist as the last element in the list.
// In addition, with the two default items, we should have a total of 3 elements in the list.
// Since assertions yield the element that was asserted on,
// we can chain both of these assertions together into a single statement.
cy.get('.todo-list li')
.should('have.length', 3)
.last()
.should('have.text', newItem)
})
it('can check off an item as completed', () => { cy.contains("Walk the dog").should("not.exist");
// In addition to using the `get` command to get an element by selector, });
// we can also use the `contains` command to get an element by its contents.
// However, this will yield the <label>, which is lowest-level element that contains the text.
// In order to check the item, we'll find the <input> element for this <label>
// by traversing up the dom to the parent element. From there, we can `find`
// the child checkbox <input> element and use the `check` command to check it.
cy.contains('Pay electric bill')
.parent()
.find('input[type=checkbox]')
.check()
// Now that we've checked the button, we can go ahead and make sure it("can delete all completed tasks", () => {
// that the list element is now marked as completed. // First, let's click the "Clear completed" button
// Again we'll use `contains` to find the <label> element and then use the `parents` command // `contains` is actually serving two purposes here.
// to traverse multiple levels up the dom until we find the corresponding <li> element. // First, it's ensuring that the button exists within the dom.
// Once we get that element, we can assert that it has the completed class. // This button only appears when at least one task is checked
cy.contains('Pay electric bill') // so this command is implicitly verifying that it does exist.
.parents('li') // Second, it selects the button so we can click it.
.should('have.class', 'completed') cy.contains("Clear completed").click();
})
context('with a checked task', () => { // Then we can make sure that there is only one element
beforeEach(() => { // in the list and our element does not exist
// We'll take the command we used above to check off an element cy.get(".todo-list li").should("have.length", 1).should("not.have.text", "Pay electric bill");
// Since we want to perform multiple tests that start with checking
// one element, we put it in the beforeEach hook
// so that it runs at the start of every test.
cy.contains('Pay electric bill')
.parent()
.find('input[type=checkbox]')
.check()
})
it('can filter for uncompleted tasks', () => { // Finally, make sure that the clear button no longer exists.
// We'll click on the "active" button in order to cy.contains("Clear completed").should("not.exist");
// display only incomplete items });
cy.contains('Active').click() });
});
// After filtering, we can assert that there is only the one
// incomplete item in the list.
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('have.text', 'Walk the dog')
// For good measure, let's also assert that the task we checked off
// does not exist on the page.
cy.contains('Pay electric bill').should('not.exist')
})
it('can filter for completed tasks', () => {
// We can perform similar steps as the test above to ensure
// that only completed tasks are shown
cy.contains('Completed').click()
cy.get('.todo-list li')
.should('have.length', 1)
.first()
.should('have.text', 'Pay electric bill')
cy.contains('Walk the dog').should('not.exist')
})
it('can delete all completed tasks', () => {
// First, let's click the "Clear completed" button
// `contains` is actually serving two purposes here.
// First, it's ensuring that the button exists within the dom.
// This button only appears when at least one task is checked
// so this command is implicitly verifying that it does exist.
// Second, it selects the button so we can click it.
cy.contains('Clear completed').click()
// Then we can make sure that there is only one element
// in the list and our element does not exist
cy.get('.todo-list li')
.should('have.length', 1)
.should('not.have.text', 'Pay electric bill')
// Finally, make sure that the clear button no longer exists.
cy.contains('Clear completed').should('not.exist')
})
})
})

View File

@@ -1,299 +1,284 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Actions', () => { context("Actions", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/actions') cy.visit("https://example.cypress.io/commands/actions");
}) });
// https://on.cypress.io/interacting-with-elements // https://on.cypress.io/interacting-with-elements
it('.type() - type into a DOM element', () => { it(".type() - type into a DOM element", () => {
// https://on.cypress.io/type // https://on.cypress.io/type
cy.get('.action-email') cy.get(".action-email")
.type('fake@email.com').should('have.value', 'fake@email.com') .type("fake@email.com")
.should("have.value", "fake@email.com")
// .type() with special character sequences // .type() with special character sequences
.type('{leftarrow}{rightarrow}{uparrow}{downarrow}') .type("{leftarrow}{rightarrow}{uparrow}{downarrow}")
.type('{del}{selectall}{backspace}') .type("{del}{selectall}{backspace}")
// .type() with key modifiers // .type() with key modifiers
.type('{alt}{option}') //these are equivalent .type("{alt}{option}") //these are equivalent
.type('{ctrl}{control}') //these are equivalent .type("{ctrl}{control}") //these are equivalent
.type('{meta}{command}{cmd}') //these are equivalent .type("{meta}{command}{cmd}") //these are equivalent
.type('{shift}') .type("{shift}")
// Delay each keypress by 0.1 sec // Delay each keypress by 0.1 sec
.type('slow.typing@email.com', {delay: 100}) .type("slow.typing@email.com", { delay: 100 })
.should('have.value', 'slow.typing@email.com') .should("have.value", "slow.typing@email.com");
cy.get('.action-disabled') cy.get(".action-disabled")
// Ignore error checking prior to type // Ignore error checking prior to type
// like whether the input is visible or disabled // like whether the input is visible or disabled
.type('disabled error checking', {force: true}) .type("disabled error checking", { force: true })
.should('have.value', 'disabled error checking') .should("have.value", "disabled error checking");
}) });
it('.focus() - focus on a DOM element', () => { it(".focus() - focus on a DOM element", () => {
// https://on.cypress.io/focus // https://on.cypress.io/focus
cy.get('.action-focus').focus() cy.get(".action-focus").focus().should("have.class", "focus").prev().should("have.attr", "style", "color: orange;");
.should('have.class', 'focus') });
.prev().should('have.attr', 'style', 'color: orange;')
})
it('.blur() - blur off a DOM element', () => { it(".blur() - blur off a DOM element", () => {
// https://on.cypress.io/blur // https://on.cypress.io/blur
cy.get('.action-blur').type('About to blur').blur() cy.get(".action-blur")
.should('have.class', 'error') .type("About to blur")
.prev().should('have.attr', 'style', 'color: red;') .blur()
}) .should("have.class", "error")
.prev()
.should("have.attr", "style", "color: red;");
});
it('.clear() - clears an input or textarea element', () => { it(".clear() - clears an input or textarea element", () => {
// https://on.cypress.io/clear // https://on.cypress.io/clear
cy.get('.action-clear').type('Clear this text') cy.get(".action-clear")
.should('have.value', 'Clear this text') .type("Clear this text")
.clear() .should("have.value", "Clear this text")
.should('have.value', '') .clear()
}) .should("have.value", "");
});
it('.submit() - submit a form', () => { it(".submit() - submit a form", () => {
// https://on.cypress.io/submit // https://on.cypress.io/submit
cy.get('.action-form') cy.get(".action-form").find('[type="text"]').type("HALFOFF");
.find('[type="text"]').type('HALFOFF')
cy.get('.action-form').submit() cy.get(".action-form").submit().next().should("contain", "Your form has been submitted!");
.next().should('contain', 'Your form has been submitted!') });
})
it('.click() - click on a DOM element', () => { it(".click() - click on a DOM element", () => {
// https://on.cypress.io/click // https://on.cypress.io/click
cy.get('.action-btn').click() cy.get(".action-btn").click();
// You can click on 9 specific positions of an element: // You can click on 9 specific positions of an element:
// ----------------------------------- // -----------------------------------
// | topLeft top topRight | // | topLeft top topRight |
// | | // | |
// | | // | |
// | | // | |
// | left center right | // | left center right |
// | | // | |
// | | // | |
// | | // | |
// | bottomLeft bottom bottomRight | // | bottomLeft bottom bottomRight |
// ----------------------------------- // -----------------------------------
// clicking in the center of the element is the default // clicking in the center of the element is the default
cy.get('#action-canvas').click() cy.get("#action-canvas").click();
cy.get('#action-canvas').click('topLeft') cy.get("#action-canvas").click("topLeft");
cy.get('#action-canvas').click('top') cy.get("#action-canvas").click("top");
cy.get('#action-canvas').click('topRight') cy.get("#action-canvas").click("topRight");
cy.get('#action-canvas').click('left') cy.get("#action-canvas").click("left");
cy.get('#action-canvas').click('right') cy.get("#action-canvas").click("right");
cy.get('#action-canvas').click('bottomLeft') cy.get("#action-canvas").click("bottomLeft");
cy.get('#action-canvas').click('bottom') cy.get("#action-canvas").click("bottom");
cy.get('#action-canvas').click('bottomRight') cy.get("#action-canvas").click("bottomRight");
// .click() accepts an x and y coordinate // .click() accepts an x and y coordinate
// that controls where the click occurs :) // that controls where the click occurs :)
cy.get('#action-canvas') cy.get("#action-canvas")
.click(80, 75) // click 80px on x coord and 75px on y coord .click(80, 75) // click 80px on x coord and 75px on y coord
.click(170, 75) .click(170, 75)
.click(80, 165) .click(80, 165)
.click(100, 185) .click(100, 185)
.click(125, 190) .click(125, 190)
.click(150, 185) .click(150, 185)
.click(170, 165) .click(170, 165);
// click multiple elements by passing multiple: true // click multiple elements by passing multiple: true
cy.get('.action-labels>.label').click({multiple: true}) cy.get(".action-labels>.label").click({ multiple: true });
// Ignore error checking prior to clicking // Ignore error checking prior to clicking
cy.get('.action-opacity>.btn').click({force: true}) cy.get(".action-opacity>.btn").click({ force: true });
}) });
it('.dblclick() - double click on a DOM element', () => { it(".dblclick() - double click on a DOM element", () => {
// https://on.cypress.io/dblclick // https://on.cypress.io/dblclick
// Our app has a listener on 'dblclick' event in our 'scripts.js' // Our app has a listener on 'dblclick' event in our 'scripts.js'
// that hides the div and shows an input on double click // that hides the div and shows an input on double click
cy.get('.action-div').dblclick().should('not.be.visible') cy.get(".action-div").dblclick().should("not.be.visible");
cy.get('.action-input-hidden').should('be.visible') cy.get(".action-input-hidden").should("be.visible");
}) });
it('.rightclick() - right click on a DOM element', () => { it(".rightclick() - right click on a DOM element", () => {
// https://on.cypress.io/rightclick // https://on.cypress.io/rightclick
// Our app has a listener on 'contextmenu' event in our 'scripts.js' // Our app has a listener on 'contextmenu' event in our 'scripts.js'
// that hides the div and shows an input on right click // that hides the div and shows an input on right click
cy.get('.rightclick-action-div').rightclick().should('not.be.visible') cy.get(".rightclick-action-div").rightclick().should("not.be.visible");
cy.get('.rightclick-action-input-hidden').should('be.visible') cy.get(".rightclick-action-input-hidden").should("be.visible");
}) });
it('.check() - check a checkbox or radio element', () => { it(".check() - check a checkbox or radio element", () => {
// https://on.cypress.io/check // https://on.cypress.io/check
// By default, .check() will check all // By default, .check() will check all
// matching checkbox or radio elements in succession, one after another // matching checkbox or radio elements in succession, one after another
cy.get('.action-checkboxes [type="checkbox"]').not('[disabled]') cy.get('.action-checkboxes [type="checkbox"]').not("[disabled]").check().should("be.checked");
.check().should('be.checked')
cy.get('.action-radios [type="radio"]').not('[disabled]') cy.get('.action-radios [type="radio"]').not("[disabled]").check().should("be.checked");
.check().should('be.checked')
// .check() accepts a value argument // .check() accepts a value argument
cy.get('.action-radios [type="radio"]') cy.get('.action-radios [type="radio"]').check("radio1").should("be.checked");
.check('radio1').should('be.checked')
// .check() accepts an array of values // .check() accepts an array of values
cy.get('.action-multiple-checkboxes [type="checkbox"]') cy.get('.action-multiple-checkboxes [type="checkbox"]').check(["checkbox1", "checkbox2"]).should("be.checked");
.check(['checkbox1', 'checkbox2']).should('be.checked')
// Ignore error checking prior to checking // Ignore error checking prior to checking
cy.get('.action-checkboxes [disabled]') cy.get(".action-checkboxes [disabled]").check({ force: true }).should("be.checked");
.check({force: true}).should('be.checked')
cy.get('.action-radios [type="radio"]') cy.get('.action-radios [type="radio"]').check("radio3", { force: true }).should("be.checked");
.check('radio3', {force: true}).should('be.checked') });
})
it('.uncheck() - uncheck a checkbox element', () => { it(".uncheck() - uncheck a checkbox element", () => {
// https://on.cypress.io/uncheck // https://on.cypress.io/uncheck
// By default, .uncheck() will uncheck all matching // By default, .uncheck() will uncheck all matching
// checkbox elements in succession, one after another // checkbox elements in succession, one after another
cy.get('.action-check [type="checkbox"]') cy.get('.action-check [type="checkbox"]').not("[disabled]").uncheck().should("not.be.checked");
.not('[disabled]')
.uncheck().should('not.be.checked')
// .uncheck() accepts a value argument // .uncheck() accepts a value argument
cy.get('.action-check [type="checkbox"]') cy.get('.action-check [type="checkbox"]').check("checkbox1").uncheck("checkbox1").should("not.be.checked");
.check('checkbox1')
.uncheck('checkbox1').should('not.be.checked')
// .uncheck() accepts an array of values // .uncheck() accepts an array of values
cy.get('.action-check [type="checkbox"]') cy.get('.action-check [type="checkbox"]')
.check(['checkbox1', 'checkbox3']) .check(["checkbox1", "checkbox3"])
.uncheck(['checkbox1', 'checkbox3']).should('not.be.checked') .uncheck(["checkbox1", "checkbox3"])
.should("not.be.checked");
// Ignore error checking prior to unchecking // Ignore error checking prior to unchecking
cy.get('.action-check [disabled]') cy.get(".action-check [disabled]").uncheck({ force: true }).should("not.be.checked");
.uncheck({force: true}).should('not.be.checked') });
})
it('.select() - select an option in a <select> element', () => { it(".select() - select an option in a <select> element", () => {
// https://on.cypress.io/select // https://on.cypress.io/select
// at first, no option should be selected // at first, no option should be selected
cy.get('.action-select') cy.get(".action-select").should("have.value", "--Select a fruit--");
.should('have.value', '--Select a fruit--')
// Select option(s) with matching text content // Select option(s) with matching text content
cy.get('.action-select').select('apples') cy.get(".action-select").select("apples");
// confirm the apples were selected // confirm the apples were selected
// note that each value starts with "fr-" in our HTML // note that each value starts with "fr-" in our HTML
cy.get('.action-select').should('have.value', 'fr-apples') cy.get(".action-select").should("have.value", "fr-apples");
cy.get('.action-select-multiple') cy.get(".action-select-multiple")
.select(['apples', 'oranges', 'bananas']) .select(["apples", "oranges", "bananas"])
// when getting multiple values, invoke "val" method first // when getting multiple values, invoke "val" method first
.invoke('val') .invoke("val")
.should('deep.equal', ['fr-apples', 'fr-oranges', 'fr-bananas']) .should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// Select option(s) with matching value // Select option(s) with matching value
cy.get('.action-select').select('fr-bananas') cy.get(".action-select")
// can attach an assertion right away to the element .select("fr-bananas")
.should('have.value', 'fr-bananas') // can attach an assertion right away to the element
.should("have.value", "fr-bananas");
cy.get('.action-select-multiple') cy.get(".action-select-multiple")
.select(['fr-apples', 'fr-oranges', 'fr-bananas']) .select(["fr-apples", "fr-oranges", "fr-bananas"])
.invoke('val') .invoke("val")
.should('deep.equal', ['fr-apples', 'fr-oranges', 'fr-bananas']) .should("deep.equal", ["fr-apples", "fr-oranges", "fr-bananas"]);
// assert the selected values include oranges // assert the selected values include oranges
cy.get('.action-select-multiple') cy.get(".action-select-multiple").invoke("val").should("include", "fr-oranges");
.invoke('val').should('include', 'fr-oranges') });
})
it('.scrollIntoView() - scroll an element into view', () => { it(".scrollIntoView() - scroll an element into view", () => {
// https://on.cypress.io/scrollintoview // https://on.cypress.io/scrollintoview
// normally all of these buttons are hidden, // normally all of these buttons are hidden,
// because they're not within // because they're not within
// the viewable area of their parent // the viewable area of their parent
// (we need to scroll to see them) // (we need to scroll to see them)
cy.get('#scroll-horizontal button') cy.get("#scroll-horizontal button").should("not.be.visible");
.should('not.be.visible')
// scroll the button into view, as if the user had scrolled // scroll the button into view, as if the user had scrolled
cy.get('#scroll-horizontal button').scrollIntoView() cy.get("#scroll-horizontal button").scrollIntoView().should("be.visible");
.should('be.visible')
cy.get('#scroll-vertical button') cy.get("#scroll-vertical button").should("not.be.visible");
.should('not.be.visible')
// Cypress handles the scroll direction needed // Cypress handles the scroll direction needed
cy.get('#scroll-vertical button').scrollIntoView() cy.get("#scroll-vertical button").scrollIntoView().should("be.visible");
.should('be.visible')
cy.get('#scroll-both button') cy.get("#scroll-both button").should("not.be.visible");
.should('not.be.visible')
// Cypress knows to scroll to the right and down // Cypress knows to scroll to the right and down
cy.get('#scroll-both button').scrollIntoView() cy.get("#scroll-both button").scrollIntoView().should("be.visible");
.should('be.visible') });
})
it('.trigger() - trigger an event on a DOM element', () => { it(".trigger() - trigger an event on a DOM element", () => {
// https://on.cypress.io/trigger // https://on.cypress.io/trigger
// To interact with a range input (slider) // To interact with a range input (slider)
// we need to set its value & trigger the // we need to set its value & trigger the
// event to signal it changed // event to signal it changed
// Here, we invoke jQuery's val() method to set // Here, we invoke jQuery's val() method to set
// the value and trigger the 'change' event // the value and trigger the 'change' event
cy.get('.trigger-input-range') cy.get(".trigger-input-range")
.invoke('val', 25) .invoke("val", 25)
.trigger('change') .trigger("change")
.get('input[type=range]').siblings('p') .get("input[type=range]")
.should('have.text', '25') .siblings("p")
}) .should("have.text", "25");
});
it('cy.scrollTo() - scroll the window or element to a position', () => { it("cy.scrollTo() - scroll the window or element to a position", () => {
// https://on.cypress.io/scrollto // https://on.cypress.io/scrollto
// You can scroll to 9 specific positions of an element: // You can scroll to 9 specific positions of an element:
// ----------------------------------- // -----------------------------------
// | topLeft top topRight | // | topLeft top topRight |
// | | // | |
// | | // | |
// | | // | |
// | left center right | // | left center right |
// | | // | |
// | | // | |
// | | // | |
// | bottomLeft bottom bottomRight | // | bottomLeft bottom bottomRight |
// ----------------------------------- // -----------------------------------
// if you chain .scrollTo() off of cy, we will // if you chain .scrollTo() off of cy, we will
// scroll the entire window // scroll the entire window
cy.scrollTo('bottom') cy.scrollTo("bottom");
cy.get('#scrollable-horizontal').scrollTo('right') cy.get("#scrollable-horizontal").scrollTo("right");
// or you can scroll to a specific coordinate: // or you can scroll to a specific coordinate:
// (x axis, y axis) in pixels // (x axis, y axis) in pixels
cy.get('#scrollable-vertical').scrollTo(250, 250) cy.get("#scrollable-vertical").scrollTo(250, 250);
// or you can scroll to a specific percentage // or you can scroll to a specific percentage
// of the (width, height) of the element // of the (width, height) of the element
cy.get('#scrollable-both').scrollTo('75%', '25%') cy.get("#scrollable-both").scrollTo("75%", "25%");
// control the easing of the scroll (default is 'swing') // control the easing of the scroll (default is 'swing')
cy.get('#scrollable-vertical').scrollTo('center', {easing: 'linear'}) cy.get("#scrollable-vertical").scrollTo("center", { easing: "linear" });
// control the duration of the scroll (in ms) // control the duration of the scroll (in ms)
cy.get('#scrollable-both').scrollTo('center', {duration: 2000}) cy.get("#scrollable-both").scrollTo("center", { duration: 2000 });
}) });
}) });

View File

@@ -1,39 +1,35 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Aliasing', () => { context("Aliasing", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/aliasing') cy.visit("https://example.cypress.io/commands/aliasing");
}) });
it('.as() - alias a DOM element for later use', () => { it(".as() - alias a DOM element for later use", () => {
// https://on.cypress.io/as // https://on.cypress.io/as
// Alias a DOM element for use later // Alias a DOM element for use later
// We don't have to traverse to the element // We don't have to traverse to the element
// later in our code, we reference it with @ // later in our code, we reference it with @
cy.get('.as-table').find('tbody>tr') cy.get(".as-table").find("tbody>tr").first().find("td").first().find("button").as("firstBtn");
.first().find('td').first()
.find('button').as('firstBtn')
// when we reference the alias, we place an // when we reference the alias, we place an
// @ in front of its name // @ in front of its name
cy.get('@firstBtn').click() cy.get("@firstBtn").click();
cy.get('@firstBtn') cy.get("@firstBtn").should("have.class", "btn-success").and("contain", "Changed");
.should('have.class', 'btn-success') });
.and('contain', 'Changed')
})
it('.as() - alias a route for later use', () => { it(".as() - alias a route for later use", () => {
// Alias the route to wait for its response // Alias the route to wait for its response
cy.intercept('GET', '**/comments/*').as('getComment') cy.intercept("GET", "**/comments/*").as("getComment");
// we have code that gets a comment when // we have code that gets a comment when
// the button is clicked in scripts.js // the button is clicked in scripts.js
cy.get('.network-btn').click() cy.get(".network-btn").click();
// https://on.cypress.io/wait // https://on.cypress.io/wait
cy.wait('@getComment').its('response.statusCode').should('eq', 200) cy.wait("@getComment").its("response.statusCode").should("eq", 200);
}) });
}) });

View File

@@ -1,177 +1,173 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Assertions', () => { context("Assertions", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/assertions') cy.visit("https://example.cypress.io/commands/assertions");
}) });
describe('Implicit Assertions', () => { describe("Implicit Assertions", () => {
it('.should() - make an assertion about the current subject', () => { it(".should() - make an assertion about the current subject", () => {
// https://on.cypress.io/should // https://on.cypress.io/should
cy.get('.assertion-table') cy.get(".assertion-table")
.find('tbody tr:last') .find("tbody tr:last")
.should('have.class', 'success') .should("have.class", "success")
.find('td') .find("td")
.first() .first()
// checking the text of the <td> element in various ways // checking the text of the <td> element in various ways
.should('have.text', 'Column content') .should("have.text", "Column content")
.should('contain', 'Column content') .should("contain", "Column content")
.should('have.html', 'Column content') .should("have.html", "Column content")
// chai-jquery uses "is()" to check if element matches selector // chai-jquery uses "is()" to check if element matches selector
.should('match', 'td') .should("match", "td")
// to match text content against a regular expression // to match text content against a regular expression
// first need to invoke jQuery method text() // first need to invoke jQuery method text()
// and then match using regular expression // and then match using regular expression
.invoke('text') .invoke("text")
.should('match', /column content/i) .should("match", /column content/i);
// a better way to check element's text content against a regular expression // a better way to check element's text content against a regular expression
// is to use "cy.contains" // is to use "cy.contains"
// https://on.cypress.io/contains // https://on.cypress.io/contains
cy.get('.assertion-table') cy.get(".assertion-table")
.find('tbody tr:last') .find("tbody tr:last")
// finds first <td> element with text content matching regular expression // finds first <td> element with text content matching regular expression
.contains('td', /column content/i) .contains("td", /column content/i)
.should('be.visible') .should("be.visible");
// for more information about asserting element's text // for more information about asserting element's text
// see https://on.cypress.io/using-cypress-faq#How-do-I-get-an-elements-text-contents // see https://on.cypress.io/using-cypress-faq#How-do-I-get-an-elements-text-contents
});
it(".and() - chain multiple assertions together", () => {
// https://on.cypress.io/and
cy.get(".assertions-link").should("have.class", "active").and("have.attr", "href").and("include", "cypress.io");
});
});
describe("Explicit Assertions", () => {
// https://on.cypress.io/assertions
it("expect - make an assertion about a specified subject", () => {
// We can use Chai's BDD style assertions
expect(true).to.be.true;
const o = { foo: "bar" };
expect(o).to.equal(o);
expect(o).to.deep.equal({ foo: "bar" });
// matching text using regular expression
expect("FooBar").to.match(/bar$/i);
});
it("pass your own callback function to should()", () => {
// Pass a function to should that can have any number
// of explicit assertions within it.
// The ".should(cb)" function will be retried
// automatically until it passes all your explicit assertions or times out.
cy.get(".assertions-p")
.find("p")
.should(($p) => {
// https://on.cypress.io/$
// return an array of texts from all of the p's
// @ts-ignore TS6133 unused variable
const texts = $p.map((i, el) => Cypress.$(el).text());
// jquery map returns jquery object
// and .get() convert this to simple array
const paragraphs = texts.get();
// array should have length of 3
expect(paragraphs, "has 3 paragraphs").to.have.length(3);
// use second argument to expect(...) to provide clear
// message with each assertion
expect(paragraphs, "has expected text in each paragraph").to.deep.eq([
"Some text from first p",
"More text from second p",
"And even more text from third p"
]);
});
});
it("finds element by class name regex", () => {
cy.get(".docs-header")
.find("div")
// .should(cb) callback function will be retried
.should(($div) => {
expect($div).to.have.length(1);
const className = $div[0].className;
expect(className).to.match(/heading-/);
}) })
// .then(cb) callback is not retried,
// it either passes or fails
.then(($div) => {
expect($div, "text content").to.have.text("Introduction");
});
});
it('.and() - chain multiple assertions together', () => { it("can throw any error", () => {
// https://on.cypress.io/and cy.get(".docs-header")
cy.get('.assertions-link') .find("div")
.should('have.class', 'active') .should(($div) => {
.and('have.attr', 'href') if ($div.length !== 1) {
.and('include', 'cypress.io') // you can throw your own errors
}) throw new Error("Did not find 1 element");
}) }
describe('Explicit Assertions', () => { const className = $div[0].className;
// https://on.cypress.io/assertions
it('expect - make an assertion about a specified subject', () => {
// We can use Chai's BDD style assertions
expect(true).to.be.true
const o = {foo: 'bar'}
expect(o).to.equal(o) if (!className.match(/heading-/)) {
expect(o).to.deep.equal({foo: 'bar'}) throw new Error(`Could not find class "heading-" in ${className}`);
// matching text using regular expression }
expect('FooBar').to.match(/bar$/i) });
}) });
it('pass your own callback function to should()', () => { it("matches unknown text between two elements", () => {
// Pass a function to should that can have any number /**
// of explicit assertions within it. * Text from the first element.
// The ".should(cb)" function will be retried * @type {string}
// automatically until it passes all your explicit assertions or times out. */
cy.get('.assertions-p') let text;
.find('p')
.should(($p) => {
// https://on.cypress.io/$
// return an array of texts from all of the p's
// @ts-ignore TS6133 unused variable
const texts = $p.map((i, el) => Cypress.$(el).text())
// jquery map returns jquery object /**
// and .get() convert this to simple array * Normalizes passed text,
const paragraphs = texts.get() * useful before comparing text with spaces and different capitalization.
* @param {string} s Text to normalize
*/
const normalizeText = (s) => s.replace(/\s/g, "").toLowerCase();
// array should have length of 3 cy.get(".two-elements")
expect(paragraphs, 'has 3 paragraphs').to.have.length(3) .find(".first")
.then(($first) => {
// save text from the first element
text = normalizeText($first.text());
});
// use second argument to expect(...) to provide clear cy.get(".two-elements")
// message with each assertion .find(".second")
expect(paragraphs, 'has expected text in each paragraph').to.deep.eq([ .should(($div) => {
'Some text from first p', // we can massage text before comparing
'More text from second p', const secondText = normalizeText($div.text());
'And even more text from third p',
])
})
})
it('finds element by class name regex', () => { expect(secondText, "second text").to.equal(text);
cy.get('.docs-header') });
.find('div') });
// .should(cb) callback function will be retried
.should(($div) => {
expect($div).to.have.length(1)
const className = $div[0].className it("assert - assert shape of an object", () => {
const person = {
name: "Joe",
age: 20
};
expect(className).to.match(/heading-/) assert.isObject(person, "value is object");
}) });
// .then(cb) callback is not retried,
// it either passes or fails
.then(($div) => {
expect($div, 'text content').to.have.text('Introduction')
})
})
it('can throw any error', () => { it("retries the should callback until assertions pass", () => {
cy.get('.docs-header') cy.get("#random-number").should(($div) => {
.find('div') const n = parseFloat($div.text());
.should(($div) => {
if ($div.length !== 1) {
// you can throw your own errors
throw new Error('Did not find 1 element')
}
const className = $div[0].className expect(n).to.be.gte(1).and.be.lte(10);
});
if (!className.match(/heading-/)) { });
throw new Error(`Could not find class "heading-" in ${className}`) });
} });
})
})
it('matches unknown text between two elements', () => {
/**
* Text from the first element.
* @type {string}
*/
let text
/**
* Normalizes passed text,
* useful before comparing text with spaces and different capitalization.
* @param {string} s Text to normalize
*/
const normalizeText = (s) => s.replace(/\s/g, '').toLowerCase()
cy.get('.two-elements')
.find('.first')
.then(($first) => {
// save text from the first element
text = normalizeText($first.text())
})
cy.get('.two-elements')
.find('.second')
.should(($div) => {
// we can massage text before comparing
const secondText = normalizeText($div.text())
expect(secondText, 'second text').to.equal(text)
})
})
it('assert - assert shape of an object', () => {
const person = {
name: 'Joe',
age: 20,
}
assert.isObject(person, 'value is object')
})
it('retries the should callback until assertions pass', () => {
cy.get('#random-number')
.should(($div) => {
const n = parseFloat($div.text())
expect(n).to.be.gte(1).and.be.lte(10)
})
})
})
})

View File

@@ -1,97 +1,96 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Connectors', () => { context("Connectors", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/connectors') cy.visit("https://example.cypress.io/commands/connectors");
}) });
it('.each() - iterate over an array of elements', () => { it(".each() - iterate over an array of elements", () => {
// https://on.cypress.io/each // https://on.cypress.io/each
cy.get('.connectors-each-ul>li') cy.get(".connectors-each-ul>li").each(($el, index, $list) => {
.each(($el, index, $list) => { console.log($el, index, $list);
console.log($el, index, $list) });
}) });
})
it('.its() - get properties on the current subject', () => { it(".its() - get properties on the current subject", () => {
// https://on.cypress.io/its // https://on.cypress.io/its
cy.get('.connectors-its-ul>li') cy.get(".connectors-its-ul>li")
// calls the 'length' property yielding that value // calls the 'length' property yielding that value
.its('length') .its("length")
.should('be.gt', 2) .should("be.gt", 2);
}) });
it('.invoke() - invoke a function on the current subject', () => { it(".invoke() - invoke a function on the current subject", () => {
// our div is hidden in our script.js // our div is hidden in our script.js
// $('.connectors-div').hide() // $('.connectors-div').hide()
// https://on.cypress.io/invoke // https://on.cypress.io/invoke
cy.get('.connectors-div').should('be.hidden') cy.get(".connectors-div")
// call the jquery method 'show' on the 'div.container' .should("be.hidden")
.invoke('show') // call the jquery method 'show' on the 'div.container'
.should('be.visible') .invoke("show")
}) .should("be.visible");
});
it('.spread() - spread an array as individual args to callback function', () => { it(".spread() - spread an array as individual args to callback function", () => {
// https://on.cypress.io/spread // https://on.cypress.io/spread
const arr = ['foo', 'bar', 'baz'] const arr = ["foo", "bar", "baz"];
cy.wrap(arr).spread((foo, bar, baz) => { cy.wrap(arr).spread((foo, bar, baz) => {
expect(foo).to.eq('foo') expect(foo).to.eq("foo");
expect(bar).to.eq('bar') expect(bar).to.eq("bar");
expect(baz).to.eq('baz') expect(baz).to.eq("baz");
});
});
describe(".then()", () => {
it("invokes a callback function with the current subject", () => {
// https://on.cypress.io/then
cy.get(".connectors-list > li").then(($lis) => {
expect($lis, "3 items").to.have.length(3);
expect($lis.eq(0), "first item").to.contain("Walk the dog");
expect($lis.eq(1), "second item").to.contain("Feed the cat");
expect($lis.eq(2), "third item").to.contain("Write JavaScript");
});
});
it("yields the returned value to the next command", () => {
cy.wrap(1)
.then((num) => {
expect(num).to.equal(1);
return 2;
}) })
}) .then((num) => {
expect(num).to.equal(2);
});
});
describe('.then()', () => { it("yields the original subject without return", () => {
it('invokes a callback function with the current subject', () => { cy.wrap(1)
// https://on.cypress.io/then .then((num) => {
cy.get('.connectors-list > li') expect(num).to.equal(1);
.then(($lis) => { // note that nothing is returned from this callback
expect($lis, '3 items').to.have.length(3)
expect($lis.eq(0), 'first item').to.contain('Walk the dog')
expect($lis.eq(1), 'second item').to.contain('Feed the cat')
expect($lis.eq(2), 'third item').to.contain('Write JavaScript')
})
}) })
.then((num) => {
// this callback receives the original unchanged value 1
expect(num).to.equal(1);
});
});
it('yields the returned value to the next command', () => { it("yields the value yielded by the last Cypress command inside", () => {
cy.wrap(1) cy.wrap(1)
.then((num) => { .then((num) => {
expect(num).to.equal(1) expect(num).to.equal(1);
// note how we run a Cypress command
return 2 // the result yielded by this Cypress command
}) // will be passed to the second ".then"
.then((num) => { cy.wrap(2);
expect(num).to.equal(2)
})
}) })
.then((num) => {
it('yields the original subject without return', () => { // this callback receives the value yielded by "cy.wrap(2)"
cy.wrap(1) expect(num).to.equal(2);
.then((num) => { });
expect(num).to.equal(1) });
// note that nothing is returned from this callback });
}) });
.then((num) => {
// this callback receives the original unchanged value 1
expect(num).to.equal(1)
})
})
it('yields the value yielded by the last Cypress command inside', () => {
cy.wrap(1)
.then((num) => {
expect(num).to.equal(1)
// note how we run a Cypress command
// the result yielded by this Cypress command
// will be passed to the second ".then"
cy.wrap(2)
})
.then((num) => {
// this callback receives the value yielded by "cy.wrap(2)"
expect(num).to.equal(2)
})
})
})
})

View File

@@ -1,77 +1,79 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Cookies', () => { context("Cookies", () => {
beforeEach(() => { beforeEach(() => {
Cypress.Cookies.debug(true) Cypress.Cookies.debug(true);
cy.visit('https://example.cypress.io/commands/cookies') cy.visit("https://example.cypress.io/commands/cookies");
// clear cookies again after visiting to remove // clear cookies again after visiting to remove
// any 3rd party cookies picked up such as cloudflare // any 3rd party cookies picked up such as cloudflare
cy.clearCookies() cy.clearCookies();
}) });
it('cy.getCookie() - get a browser cookie', () => { it("cy.getCookie() - get a browser cookie", () => {
// https://on.cypress.io/getcookie // https://on.cypress.io/getcookie
cy.get('#getCookie .set-a-cookie').click() cy.get("#getCookie .set-a-cookie").click();
// cy.getCookie() yields a cookie object // cy.getCookie() yields a cookie object
cy.getCookie('token').should('have.property', 'value', '123ABC') cy.getCookie("token").should("have.property", "value", "123ABC");
}) });
it('cy.getCookies() - get browser cookies', () => { it("cy.getCookies() - get browser cookies", () => {
// https://on.cypress.io/getcookies // https://on.cypress.io/getcookies
cy.getCookies().should('be.empty') cy.getCookies().should("be.empty");
cy.get('#getCookies .set-a-cookie').click() cy.get("#getCookies .set-a-cookie").click();
// cy.getCookies() yields an array of cookies // cy.getCookies() yields an array of cookies
cy.getCookies().should('have.length', 1).should((cookies) => { cy.getCookies()
// each cookie has these properties .should("have.length", 1)
expect(cookies[0]).to.have.property('name', 'token') .should((cookies) => {
expect(cookies[0]).to.have.property('value', '123ABC') // each cookie has these properties
expect(cookies[0]).to.have.property('httpOnly', false) expect(cookies[0]).to.have.property("name", "token");
expect(cookies[0]).to.have.property('secure', false) expect(cookies[0]).to.have.property("value", "123ABC");
expect(cookies[0]).to.have.property('domain') expect(cookies[0]).to.have.property("httpOnly", false);
expect(cookies[0]).to.have.property('path') expect(cookies[0]).to.have.property("secure", false);
}) expect(cookies[0]).to.have.property("domain");
}) expect(cookies[0]).to.have.property("path");
});
});
it('cy.setCookie() - set a browser cookie', () => { it("cy.setCookie() - set a browser cookie", () => {
// https://on.cypress.io/setcookie // https://on.cypress.io/setcookie
cy.getCookies().should('be.empty') cy.getCookies().should("be.empty");
cy.setCookie('foo', 'bar') cy.setCookie("foo", "bar");
// cy.getCookie() yields a cookie object // cy.getCookie() yields a cookie object
cy.getCookie('foo').should('have.property', 'value', 'bar') cy.getCookie("foo").should("have.property", "value", "bar");
}) });
it('cy.clearCookie() - clear a browser cookie', () => { it("cy.clearCookie() - clear a browser cookie", () => {
// https://on.cypress.io/clearcookie // https://on.cypress.io/clearcookie
cy.getCookie('token').should('be.null') cy.getCookie("token").should("be.null");
cy.get('#clearCookie .set-a-cookie').click() cy.get("#clearCookie .set-a-cookie").click();
cy.getCookie('token').should('have.property', 'value', '123ABC') cy.getCookie("token").should("have.property", "value", "123ABC");
// cy.clearCookies() yields null // cy.clearCookies() yields null
cy.clearCookie('token').should('be.null') cy.clearCookie("token").should("be.null");
cy.getCookie('token').should('be.null') cy.getCookie("token").should("be.null");
}) });
it('cy.clearCookies() - clear browser cookies', () => { it("cy.clearCookies() - clear browser cookies", () => {
// https://on.cypress.io/clearcookies // https://on.cypress.io/clearcookies
cy.getCookies().should('be.empty') cy.getCookies().should("be.empty");
cy.get('#clearCookies .set-a-cookie').click() cy.get("#clearCookies .set-a-cookie").click();
cy.getCookies().should('have.length', 1) cy.getCookies().should("have.length", 1);
// cy.clearCookies() yields null // cy.clearCookies() yields null
cy.clearCookies() cy.clearCookies();
cy.getCookies().should('be.empty') cy.getCookies().should("be.empty");
}) });
}) });

View File

@@ -1,202 +1,208 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Cypress.Commands', () => { context("Cypress.Commands", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
// https://on.cypress.io/custom-commands // https://on.cypress.io/custom-commands
it('.add() - create a custom command', () => { it(".add() - create a custom command", () => {
Cypress.Commands.add('console', { Cypress.Commands.add(
prevSubject: true, "console",
}, (subject, method) => { {
// the previous subject is automatically received prevSubject: true
// and the commands arguments are shifted },
(subject, method) => {
// the previous subject is automatically received
// and the commands arguments are shifted
// allow us to change the console method used // allow us to change the console method used
method = method || 'log' method = method || "log";
// log the subject to the console // log the subject to the console
// @ts-ignore TS7017 // @ts-ignore TS7017
console[method]('The subject is', subject) console[method]("The subject is", subject);
// whatever we return becomes the new subject // whatever we return becomes the new subject
// we don't want to change the subject so // we don't want to change the subject so
// we return whatever was passed in // we return whatever was passed in
return subject return subject;
}) }
);
// @ts-ignore TS2339 // @ts-ignore TS2339
cy.get('button').console('info').then(($button) => { cy.get("button")
// subject is still $button .console("info")
}) .then(($button) => {
}) // subject is still $button
}) });
});
});
context('Cypress.Cookies', () => { context("Cypress.Cookies", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
// https://on.cypress.io/cookies // https://on.cypress.io/cookies
it('.debug() - enable or disable debugging', () => { it(".debug() - enable or disable debugging", () => {
Cypress.Cookies.debug(true) Cypress.Cookies.debug(true);
// Cypress will now log in the console when // Cypress will now log in the console when
// cookies are set or cleared // cookies are set or cleared
cy.setCookie('fakeCookie', '123ABC') cy.setCookie("fakeCookie", "123ABC");
cy.clearCookie('fakeCookie') cy.clearCookie("fakeCookie");
cy.setCookie('fakeCookie', '123ABC') cy.setCookie("fakeCookie", "123ABC");
cy.clearCookie('fakeCookie') cy.clearCookie("fakeCookie");
cy.setCookie('fakeCookie', '123ABC') cy.setCookie("fakeCookie", "123ABC");
}) });
it('.preserveOnce() - preserve cookies by key', () => { it(".preserveOnce() - preserve cookies by key", () => {
// normally cookies are reset after each test // normally cookies are reset after each test
cy.getCookie('fakeCookie').should('not.be.ok') cy.getCookie("fakeCookie").should("not.be.ok");
// preserving a cookie will not clear it when // preserving a cookie will not clear it when
// the next test starts // the next test starts
cy.setCookie('lastCookie', '789XYZ') cy.setCookie("lastCookie", "789XYZ");
Cypress.Cookies.preserveOnce('lastCookie') Cypress.Cookies.preserveOnce("lastCookie");
}) });
it('.defaults() - set defaults for all cookies', () => { it(".defaults() - set defaults for all cookies", () => {
// now any cookie with the name 'session_id' will // now any cookie with the name 'session_id' will
// not be cleared before each new test runs // not be cleared before each new test runs
Cypress.Cookies.defaults({ Cypress.Cookies.defaults({
preserve: 'session_id', preserve: "session_id"
}) });
}) });
}) });
context('Cypress.arch', () => { context("Cypress.arch", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Get CPU architecture name of underlying OS', () => { it("Get CPU architecture name of underlying OS", () => {
// https://on.cypress.io/arch // https://on.cypress.io/arch
expect(Cypress.arch).to.exist expect(Cypress.arch).to.exist;
}) });
}) });
context('Cypress.config()', () => { context("Cypress.config()", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Get and set configuration options', () => { it("Get and set configuration options", () => {
// https://on.cypress.io/config // https://on.cypress.io/config
let myConfig = Cypress.config() let myConfig = Cypress.config();
expect(myConfig).to.have.property('animationDistanceThreshold', 5) expect(myConfig).to.have.property("animationDistanceThreshold", 5);
expect(myConfig).to.have.property('baseUrl', null) expect(myConfig).to.have.property("baseUrl", null);
expect(myConfig).to.have.property('defaultCommandTimeout', 4000) expect(myConfig).to.have.property("defaultCommandTimeout", 4000);
expect(myConfig).to.have.property('requestTimeout', 5000) expect(myConfig).to.have.property("requestTimeout", 5000);
expect(myConfig).to.have.property('responseTimeout', 30000) expect(myConfig).to.have.property("responseTimeout", 30000);
expect(myConfig).to.have.property('viewportHeight', 660) expect(myConfig).to.have.property("viewportHeight", 660);
expect(myConfig).to.have.property('viewportWidth', 1000) expect(myConfig).to.have.property("viewportWidth", 1000);
expect(myConfig).to.have.property('pageLoadTimeout', 60000) expect(myConfig).to.have.property("pageLoadTimeout", 60000);
expect(myConfig).to.have.property('waitForAnimations', true) expect(myConfig).to.have.property("waitForAnimations", true);
expect(Cypress.config('pageLoadTimeout')).to.eq(60000) expect(Cypress.config("pageLoadTimeout")).to.eq(60000);
// this will change the config for the rest of your tests! // this will change the config for the rest of your tests!
Cypress.config('pageLoadTimeout', 20000) Cypress.config("pageLoadTimeout", 20000);
expect(Cypress.config('pageLoadTimeout')).to.eq(20000) expect(Cypress.config("pageLoadTimeout")).to.eq(20000);
Cypress.config('pageLoadTimeout', 60000) Cypress.config("pageLoadTimeout", 60000);
}) });
}) });
context('Cypress.dom', () => { context("Cypress.dom", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
// https://on.cypress.io/dom // https://on.cypress.io/dom
it('.isHidden() - determine if a DOM element is hidden', () => { it(".isHidden() - determine if a DOM element is hidden", () => {
let hiddenP = Cypress.$('.dom-p p.hidden').get(0) let hiddenP = Cypress.$(".dom-p p.hidden").get(0);
let visibleP = Cypress.$('.dom-p p.visible').get(0) let visibleP = Cypress.$(".dom-p p.visible").get(0);
// our first paragraph has css class 'hidden' // our first paragraph has css class 'hidden'
expect(Cypress.dom.isHidden(hiddenP)).to.be.true expect(Cypress.dom.isHidden(hiddenP)).to.be.true;
expect(Cypress.dom.isHidden(visibleP)).to.be.false expect(Cypress.dom.isHidden(visibleP)).to.be.false;
}) });
}) });
context('Cypress.env()', () => { context("Cypress.env()", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
// We can set environment variables for highly dynamic values // We can set environment variables for highly dynamic values
// https://on.cypress.io/environment-variables // https://on.cypress.io/environment-variables
it('Get environment variables', () => { it("Get environment variables", () => {
// https://on.cypress.io/env // https://on.cypress.io/env
// set multiple environment variables // set multiple environment variables
Cypress.env({ Cypress.env({
host: 'veronica.dev.local', host: "veronica.dev.local",
api_server: 'http://localhost:8888/v1/', api_server: "http://localhost:8888/v1/"
}) });
// get environment variable // get environment variable
expect(Cypress.env('host')).to.eq('veronica.dev.local') expect(Cypress.env("host")).to.eq("veronica.dev.local");
// set environment variable // set environment variable
Cypress.env('api_server', 'http://localhost:8888/v2/') Cypress.env("api_server", "http://localhost:8888/v2/");
expect(Cypress.env('api_server')).to.eq('http://localhost:8888/v2/') expect(Cypress.env("api_server")).to.eq("http://localhost:8888/v2/");
// get all environment variable // get all environment variable
expect(Cypress.env()).to.have.property('host', 'veronica.dev.local') expect(Cypress.env()).to.have.property("host", "veronica.dev.local");
expect(Cypress.env()).to.have.property('api_server', 'http://localhost:8888/v2/') expect(Cypress.env()).to.have.property("api_server", "http://localhost:8888/v2/");
}) });
}) });
context('Cypress.log', () => { context("Cypress.log", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Control what is printed to the Command Log', () => { it("Control what is printed to the Command Log", () => {
// https://on.cypress.io/cypress-log // https://on.cypress.io/cypress-log
}) });
}) });
context('Cypress.platform', () => { context("Cypress.platform", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Get underlying OS name', () => { it("Get underlying OS name", () => {
// https://on.cypress.io/platform // https://on.cypress.io/platform
expect(Cypress.platform).to.be.exist expect(Cypress.platform).to.be.exist;
}) });
}) });
context('Cypress.version', () => { context("Cypress.version", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Get current version of Cypress being run', () => { it("Get current version of Cypress being run", () => {
// https://on.cypress.io/version // https://on.cypress.io/version
expect(Cypress.version).to.be.exist expect(Cypress.version).to.be.exist;
}) });
}) });
context('Cypress.spec', () => { context("Cypress.spec", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/cypress-api') cy.visit("https://example.cypress.io/cypress-api");
}) });
it('Get current spec information', () => { it("Get current spec information", () => {
// https://on.cypress.io/spec // https://on.cypress.io/spec
// wrap the object so we can inspect it easily by clicking in the command log // wrap the object so we can inspect it easily by clicking in the command log
cy.wrap(Cypress.spec).should('include.keys', ['name', 'relative', 'absolute']) cy.wrap(Cypress.spec).should("include.keys", ["name", "relative", "absolute"]);
}) });
}) });

View File

@@ -3,86 +3,84 @@
/// JSON fixture file can be loaded directly using /// JSON fixture file can be loaded directly using
// the built-in JavaScript bundler // the built-in JavaScript bundler
// @ts-ignore // @ts-ignore
const requiredExample = require('../../fixtures/example') const requiredExample = require("../../fixtures/example");
context('Files', () => { context("Files", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/files') cy.visit("https://example.cypress.io/commands/files");
}) });
beforeEach(() => { beforeEach(() => {
// load example.json fixture file and store // load example.json fixture file and store
// in the test context object // in the test context object
cy.fixture('example.json').as('example') cy.fixture("example.json").as("example");
}) });
it('cy.fixture() - load a fixture', () => { it("cy.fixture() - load a fixture", () => {
// https://on.cypress.io/fixture // https://on.cypress.io/fixture
// Instead of writing a response inline you can // Instead of writing a response inline you can
// use a fixture file's content. // use a fixture file's content.
// when application makes an Ajax request matching "GET **/comments/*" // when application makes an Ajax request matching "GET **/comments/*"
// Cypress will intercept it and reply with the object in `example.json` fixture // Cypress will intercept it and reply with the object in `example.json` fixture
cy.intercept('GET', '**/comments/*', {fixture: 'example.json'}).as('getComment') cy.intercept("GET", "**/comments/*", { fixture: "example.json" }).as("getComment");
// we have code that gets a comment when // we have code that gets a comment when
// the button is clicked in scripts.js // the button is clicked in scripts.js
cy.get('.fixture-btn').click() cy.get(".fixture-btn").click();
cy.wait('@getComment').its('response.body') cy.wait("@getComment")
.should('have.property', 'name') .its("response.body")
.and('include', 'Using fixtures to represent data') .should("have.property", "name")
}) .and("include", "Using fixtures to represent data");
});
it('cy.fixture() or require - load a fixture', function () { it("cy.fixture() or require - load a fixture", function () {
// we are inside the "function () { ... }" // we are inside the "function () { ... }"
// callback and can use test context object "this" // callback and can use test context object "this"
// "this.example" was loaded in "beforeEach" function callback // "this.example" was loaded in "beforeEach" function callback
expect(this.example, 'fixture in the test context') expect(this.example, "fixture in the test context").to.deep.equal(requiredExample);
.to.deep.equal(requiredExample)
// or use "cy.wrap" and "should('deep.equal', ...)" assertion // or use "cy.wrap" and "should('deep.equal', ...)" assertion
cy.wrap(this.example) cy.wrap(this.example).should("deep.equal", requiredExample);
.should('deep.equal', requiredExample) });
})
it('cy.readFile() - read file contents', () => { it("cy.readFile() - read file contents", () => {
// https://on.cypress.io/readfile // https://on.cypress.io/readfile
// You can read a file and yield its contents // You can read a file and yield its contents
// The filePath is relative to your project's root. // The filePath is relative to your project's root.
cy.readFile('cypress.json').then((json) => { cy.readFile("cypress.json").then((json) => {
expect(json).to.be.an('object') expect(json).to.be.an("object");
}) });
}) });
it('cy.writeFile() - write to a file', () => { it("cy.writeFile() - write to a file", () => {
// https://on.cypress.io/writefile // https://on.cypress.io/writefile
// You can write to a file // You can write to a file
// Use a response from a request to automatically // Use a response from a request to automatically
// generate a fixture file for use later // generate a fixture file for use later
cy.request('https://jsonplaceholder.cypress.io/users') cy.request("https://jsonplaceholder.cypress.io/users").then((response) => {
.then((response) => { cy.writeFile("cypress/fixtures/users.json", response.body);
cy.writeFile('cypress/fixtures/users.json', response.body) });
})
cy.fixture('users').should((users) => { cy.fixture("users").should((users) => {
expect(users[0].name).to.exist expect(users[0].name).to.exist;
}) });
// JavaScript arrays and objects are stringified // JavaScript arrays and objects are stringified
// and formatted into text. // and formatted into text.
cy.writeFile('cypress/fixtures/profile.json', { cy.writeFile("cypress/fixtures/profile.json", {
id: 8739, id: 8739,
name: 'Jane', name: "Jane",
email: 'jane@example.com', email: "jane@example.com"
}) });
cy.fixture('profile').should((profile) => { cy.fixture("profile").should((profile) => {
expect(profile.name).to.eq('Jane') expect(profile.name).to.eq("Jane");
}) });
}) });
}) });

View File

@@ -1,52 +1,58 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Local Storage', () => { context("Local Storage", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/local-storage') cy.visit("https://example.cypress.io/commands/local-storage");
}) });
// Although local storage is automatically cleared // Although local storage is automatically cleared
// in between tests to maintain a clean state // in between tests to maintain a clean state
// sometimes we need to clear the local storage manually // sometimes we need to clear the local storage manually
it('cy.clearLocalStorage() - clear all data in local storage', () => { it("cy.clearLocalStorage() - clear all data in local storage", () => {
// https://on.cypress.io/clearlocalstorage // https://on.cypress.io/clearlocalstorage
cy.get('.ls-btn').click().should(() => { cy.get(".ls-btn")
expect(localStorage.getItem('prop1')).to.eq('red') .click()
expect(localStorage.getItem('prop2')).to.eq('blue') .should(() => {
expect(localStorage.getItem('prop3')).to.eq('magenta') expect(localStorage.getItem("prop1")).to.eq("red");
}) expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// clearLocalStorage() yields the localStorage object // clearLocalStorage() yields the localStorage object
cy.clearLocalStorage().should((ls) => { cy.clearLocalStorage().should((ls) => {
expect(ls.getItem('prop1')).to.be.null expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem('prop2')).to.be.null expect(ls.getItem("prop2")).to.be.null;
expect(ls.getItem('prop3')).to.be.null expect(ls.getItem("prop3")).to.be.null;
}) });
cy.get('.ls-btn').click().should(() => { cy.get(".ls-btn")
expect(localStorage.getItem('prop1')).to.eq('red') .click()
expect(localStorage.getItem('prop2')).to.eq('blue') .should(() => {
expect(localStorage.getItem('prop3')).to.eq('magenta') expect(localStorage.getItem("prop1")).to.eq("red");
}) expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// Clear key matching string in Local Storage // Clear key matching string in Local Storage
cy.clearLocalStorage('prop1').should((ls) => { cy.clearLocalStorage("prop1").should((ls) => {
expect(ls.getItem('prop1')).to.be.null expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem('prop2')).to.eq('blue') expect(ls.getItem("prop2")).to.eq("blue");
expect(ls.getItem('prop3')).to.eq('magenta') expect(ls.getItem("prop3")).to.eq("magenta");
}) });
cy.get('.ls-btn').click().should(() => { cy.get(".ls-btn")
expect(localStorage.getItem('prop1')).to.eq('red') .click()
expect(localStorage.getItem('prop2')).to.eq('blue') .should(() => {
expect(localStorage.getItem('prop3')).to.eq('magenta') expect(localStorage.getItem("prop1")).to.eq("red");
}) expect(localStorage.getItem("prop2")).to.eq("blue");
expect(localStorage.getItem("prop3")).to.eq("magenta");
});
// Clear keys matching regex in Local Storage // Clear keys matching regex in Local Storage
cy.clearLocalStorage(/prop1|2/).should((ls) => { cy.clearLocalStorage(/prop1|2/).should((ls) => {
expect(ls.getItem('prop1')).to.be.null expect(ls.getItem("prop1")).to.be.null;
expect(ls.getItem('prop2')).to.be.null expect(ls.getItem("prop2")).to.be.null;
expect(ls.getItem('prop3')).to.eq('magenta') expect(ls.getItem("prop3")).to.eq("magenta");
}) });
}) });
}) });

View File

@@ -1,32 +1,32 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Location', () => { context("Location", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/location') cy.visit("https://example.cypress.io/commands/location");
}) });
it('cy.hash() - get the current URL hash', () => { it("cy.hash() - get the current URL hash", () => {
// https://on.cypress.io/hash // https://on.cypress.io/hash
cy.hash().should('be.empty') cy.hash().should("be.empty");
}) });
it('cy.location() - get window.location', () => { it("cy.location() - get window.location", () => {
// https://on.cypress.io/location // https://on.cypress.io/location
cy.location().should((location) => { cy.location().should((location) => {
expect(location.hash).to.be.empty expect(location.hash).to.be.empty;
expect(location.href).to.eq('https://example.cypress.io/commands/location') expect(location.href).to.eq("https://example.cypress.io/commands/location");
expect(location.host).to.eq('example.cypress.io') expect(location.host).to.eq("example.cypress.io");
expect(location.hostname).to.eq('example.cypress.io') expect(location.hostname).to.eq("example.cypress.io");
expect(location.origin).to.eq('https://example.cypress.io') expect(location.origin).to.eq("https://example.cypress.io");
expect(location.pathname).to.eq('/commands/location') expect(location.pathname).to.eq("/commands/location");
expect(location.port).to.eq('') expect(location.port).to.eq("");
expect(location.protocol).to.eq('https:') expect(location.protocol).to.eq("https:");
expect(location.search).to.be.empty expect(location.search).to.be.empty;
}) });
}) });
it('cy.url() - get the current URL', () => { it("cy.url() - get the current URL", () => {
// https://on.cypress.io/url // https://on.cypress.io/url
cy.url().should('eq', 'https://example.cypress.io/commands/location') cy.url().should("eq", "https://example.cypress.io/commands/location");
}) });
}) });

View File

@@ -1,106 +1,98 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Misc', () => { context("Misc", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/misc') cy.visit("https://example.cypress.io/commands/misc");
}) });
it('.end() - end the command chain', () => { it(".end() - end the command chain", () => {
// https://on.cypress.io/end // https://on.cypress.io/end
// cy.end is useful when you want to end a chain of commands // cy.end is useful when you want to end a chain of commands
// and force Cypress to re-query from the root element // and force Cypress to re-query from the root element
cy.get('.misc-table').within(() => { cy.get(".misc-table").within(() => {
// ends the current chain and yields null // ends the current chain and yields null
cy.contains('Cheryl').click().end() cy.contains("Cheryl").click().end();
// queries the entire table again // queries the entire table again
cy.contains('Charles').click() cy.contains("Charles").click();
}) });
}) });
it('cy.exec() - execute a system command', () => { it("cy.exec() - execute a system command", () => {
// execute a system command. // execute a system command.
// so you can take actions necessary for // so you can take actions necessary for
// your test outside the scope of Cypress. // your test outside the scope of Cypress.
// https://on.cypress.io/exec // https://on.cypress.io/exec
// we can use Cypress.platform string to // we can use Cypress.platform string to
// select appropriate command // select appropriate command
// https://on.cypress/io/platform // https://on.cypress/io/platform
cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`) cy.log(`Platform ${Cypress.platform} architecture ${Cypress.arch}`);
// on CircleCI Windows build machines we have a failure to run bash shell // on CircleCI Windows build machines we have a failure to run bash shell
// https://github.com/cypress-io/cypress/issues/5169 // https://github.com/cypress-io/cypress/issues/5169
// so skip some of the tests by passing flag "--env circle=true" // so skip some of the tests by passing flag "--env circle=true"
const isCircleOnWindows = Cypress.platform === 'win32' && Cypress.env('circle') const isCircleOnWindows = Cypress.platform === "win32" && Cypress.env("circle");
if (isCircleOnWindows) { if (isCircleOnWindows) {
cy.log('Skipping test on CircleCI') cy.log("Skipping test on CircleCI");
return return;
} }
// cy.exec problem on Shippable CI // cy.exec problem on Shippable CI
// https://github.com/cypress-io/cypress/issues/6718 // https://github.com/cypress-io/cypress/issues/6718
const isShippable = Cypress.platform === 'linux' && Cypress.env('shippable') const isShippable = Cypress.platform === "linux" && Cypress.env("shippable");
if (isShippable) { if (isShippable) {
cy.log('Skipping test on ShippableCI') cy.log("Skipping test on ShippableCI");
return return;
} }
cy.exec('echo Jane Lane') cy.exec("echo Jane Lane").its("stdout").should("contain", "Jane Lane");
.its('stdout').should('contain', 'Jane Lane')
if (Cypress.platform === 'win32') { if (Cypress.platform === "win32") {
cy.exec('print cypress.json') cy.exec("print cypress.json").its("stderr").should("be.empty");
.its('stderr').should('be.empty') } else {
} else { cy.exec("cat cypress.json").its("stderr").should("be.empty");
cy.exec('cat cypress.json')
.its('stderr').should('be.empty')
cy.exec('pwd') cy.exec("pwd").its("code").should("eq", 0);
.its('code').should('eq', 0) }
} });
})
it('cy.focused() - get the DOM element that has focus', () => { it("cy.focused() - get the DOM element that has focus", () => {
// https://on.cypress.io/focused // https://on.cypress.io/focused
cy.get('.misc-form').find('#name').click() cy.get(".misc-form").find("#name").click();
cy.focused().should('have.id', 'name') cy.focused().should("have.id", "name");
cy.get('.misc-form').find('#description').click() cy.get(".misc-form").find("#description").click();
cy.focused().should('have.id', 'description') cy.focused().should("have.id", "description");
}) });
context('Cypress.Screenshot', function () { context("Cypress.Screenshot", function () {
it('cy.screenshot() - take a screenshot', () => { it("cy.screenshot() - take a screenshot", () => {
// https://on.cypress.io/screenshot // https://on.cypress.io/screenshot
cy.screenshot('my-image') cy.screenshot("my-image");
}) });
it('Cypress.Screenshot.defaults() - change default config of screenshots', function () { it("Cypress.Screenshot.defaults() - change default config of screenshots", function () {
Cypress.Screenshot.defaults({ Cypress.Screenshot.defaults({
blackout: ['.foo'], blackout: [".foo"],
capture: 'viewport', capture: "viewport",
clip: {x: 0, y: 0, width: 200, height: 200}, clip: { x: 0, y: 0, width: 200, height: 200 },
scale: false, scale: false,
disableTimersAndAnimations: true, disableTimersAndAnimations: true,
screenshotOnRunFailure: true, screenshotOnRunFailure: true,
onBeforeScreenshot() { onBeforeScreenshot() {},
}, onAfterScreenshot() {}
onAfterScreenshot() { });
}, });
}) });
})
})
it('cy.wrap() - wrap an object', () => { it("cy.wrap() - wrap an object", () => {
// https://on.cypress.io/wrap // https://on.cypress.io/wrap
cy.wrap({foo: 'bar'}) cy.wrap({ foo: "bar" }).should("have.property", "foo").and("include", "bar");
.should('have.property', 'foo') });
.and('include', 'bar') });
})
})

View File

@@ -1,56 +1,56 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Navigation', () => { context("Navigation", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io') cy.visit("https://example.cypress.io");
cy.get('.navbar-nav').contains('Commands').click() cy.get(".navbar-nav").contains("Commands").click();
cy.get('.dropdown-menu').contains('Navigation').click() cy.get(".dropdown-menu").contains("Navigation").click();
}) });
it('cy.go() - go back or forward in the browser\'s history', () => { it("cy.go() - go back or forward in the browser's history", () => {
// https://on.cypress.io/go // https://on.cypress.io/go
cy.location('pathname').should('include', 'navigation') cy.location("pathname").should("include", "navigation");
cy.go('back') cy.go("back");
cy.location('pathname').should('not.include', 'navigation') cy.location("pathname").should("not.include", "navigation");
cy.go('forward') cy.go("forward");
cy.location('pathname').should('include', 'navigation') cy.location("pathname").should("include", "navigation");
// clicking back // clicking back
cy.go(-1) cy.go(-1);
cy.location('pathname').should('not.include', 'navigation') cy.location("pathname").should("not.include", "navigation");
// clicking forward // clicking forward
cy.go(1) cy.go(1);
cy.location('pathname').should('include', 'navigation') cy.location("pathname").should("include", "navigation");
}) });
it('cy.reload() - reload the page', () => { it("cy.reload() - reload the page", () => {
// https://on.cypress.io/reload // https://on.cypress.io/reload
cy.reload() cy.reload();
// reload the page without using the cache // reload the page without using the cache
cy.reload(true) cy.reload(true);
}) });
it('cy.visit() - visit a remote url', () => { it("cy.visit() - visit a remote url", () => {
// https://on.cypress.io/visit // https://on.cypress.io/visit
// Visit any sub-domain of your current domain // Visit any sub-domain of your current domain
// Pass options to the visit // Pass options to the visit
cy.visit('https://example.cypress.io/commands/navigation', { cy.visit("https://example.cypress.io/commands/navigation", {
timeout: 50000, // increase total time for the visit to resolve timeout: 50000, // increase total time for the visit to resolve
onBeforeLoad(contentWindow) { onBeforeLoad(contentWindow) {
// contentWindow is the remote page's window object // contentWindow is the remote page's window object
expect(typeof contentWindow === 'object').to.be.true expect(typeof contentWindow === "object").to.be.true;
}, },
onLoad(contentWindow) { onLoad(contentWindow) {
// contentWindow is the remote page's window object // contentWindow is the remote page's window object
expect(typeof contentWindow === 'object').to.be.true expect(typeof contentWindow === "object").to.be.true;
}, }
}) });
}) });
}) });

View File

@@ -1,163 +1,165 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Network Requests', () => { context("Network Requests", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/network-requests') cy.visit("https://example.cypress.io/commands/network-requests");
});
// Manage HTTP requests in your app
it("cy.request() - make an XHR request", () => {
// https://on.cypress.io/request
cy.request("https://jsonplaceholder.cypress.io/comments").should((response) => {
expect(response.status).to.eq(200);
// the server sometimes gets an extra comment posted from another machine
// which gets returned as 1 extra object
expect(response.body).to.have.property("length").and.be.oneOf([500, 501]);
expect(response).to.have.property("headers");
expect(response).to.have.property("duration");
});
});
it("cy.request() - verify response using BDD syntax", () => {
cy.request("https://jsonplaceholder.cypress.io/comments").then((response) => {
// https://on.cypress.io/assertions
expect(response).property("status").to.equal(200);
expect(response).property("body").to.have.property("length").and.be.oneOf([500, 501]);
expect(response).to.include.keys("headers", "duration");
});
});
it("cy.request() with query parameters", () => {
// will execute request
// https://jsonplaceholder.cypress.io/comments?postId=1&id=3
cy.request({
url: "https://jsonplaceholder.cypress.io/comments",
qs: {
postId: 1,
id: 3
}
}) })
.its("body")
.should("be.an", "array")
.and("have.length", 1)
.its("0") // yields first element of the array
.should("contain", {
postId: 1,
id: 3
});
});
// Manage HTTP requests in your app it("cy.request() - pass result to the second request", () => {
// first, let's find out the userId of the first user we have
cy.request("https://jsonplaceholder.cypress.io/users?_limit=1")
.its("body") // yields the response object
.its("0") // yields the first element of the returned list
// the above two commands its('body').its('0')
// can be written as its('body.0')
// if you do not care about TypeScript checks
.then((user) => {
expect(user).property("id").to.be.a("number");
// make a new post on behalf of the user
cy.request("POST", "https://jsonplaceholder.cypress.io/posts", {
userId: user.id,
title: "Cypress Test Runner",
body: "Fast, easy and reliable testing for anything that runs in a browser."
});
})
// note that the value here is the returned value of the 2nd request
// which is the new post object
.then((response) => {
expect(response).property("status").to.equal(201); // new entity created
expect(response).property("body").to.contain({
title: "Cypress Test Runner"
});
it('cy.request() - make an XHR request', () => { // we don't know the exact post id - only that it will be > 100
// https://on.cypress.io/request // since JSONPlaceholder has built-in 100 posts
cy.request('https://jsonplaceholder.cypress.io/comments') expect(response.body).property("id").to.be.a("number").and.to.be.gt(100);
.should((response) => {
expect(response.status).to.eq(200)
// the server sometimes gets an extra comment posted from another machine
// which gets returned as 1 extra object
expect(response.body).to.have.property('length').and.be.oneOf([500, 501])
expect(response).to.have.property('headers')
expect(response).to.have.property('duration')
})
})
it('cy.request() - verify response using BDD syntax', () => { // we don't know the user id here - since it was in above closure
cy.request('https://jsonplaceholder.cypress.io/comments') // so in this test just confirm that the property is there
.then((response) => { expect(response.body).property("userId").to.be.a("number");
// https://on.cypress.io/assertions });
expect(response).property('status').to.equal(200) });
expect(response).property('body').to.have.property('length').and.be.oneOf([500, 501])
expect(response).to.include.keys('headers', 'duration')
})
})
it('cy.request() with query parameters', () => { it("cy.request() - save response in the shared test context", () => {
// will execute request // https://on.cypress.io/variables-and-aliases
// https://jsonplaceholder.cypress.io/comments?postId=1&id=3 cy.request("https://jsonplaceholder.cypress.io/users?_limit=1")
cy.request({ .its("body")
url: 'https://jsonplaceholder.cypress.io/comments', .its("0") // yields the first element of the returned list
qs: { .as("user") // saves the object in the test context
postId: 1, .then(function () {
id: 3, // NOTE 👀
}, // By the time this callback runs the "as('user')" command
// has saved the user object in the test context.
// To access the test context we need to use
// the "function () { ... }" callback form,
// otherwise "this" points at a wrong or undefined object!
cy.request("POST", "https://jsonplaceholder.cypress.io/posts", {
userId: this.user.id,
title: "Cypress Test Runner",
body: "Fast, easy and reliable testing for anything that runs in a browser."
}) })
.its('body') .its("body")
.should('be.an', 'array') .as("post"); // save the new post from the response
.and('have.length', 1) })
.its('0') // yields first element of the array .then(function () {
.should('contain', { // When this callback runs, both "cy.request" API commands have finished
postId: 1, // and the test context has "user" and "post" objects set.
id: 3, // Let's verify them.
}) expect(this.post, "post has the right user id").property("userId").to.equal(this.user.id);
}) });
});
it('cy.request() - pass result to the second request', () => { it("cy.intercept() - route responses to matching requests", () => {
// first, let's find out the userId of the first user we have // https://on.cypress.io/intercept
cy.request('https://jsonplaceholder.cypress.io/users?_limit=1')
.its('body') // yields the response object
.its('0') // yields the first element of the returned list
// the above two commands its('body').its('0')
// can be written as its('body.0')
// if you do not care about TypeScript checks
.then((user) => {
expect(user).property('id').to.be.a('number')
// make a new post on behalf of the user
cy.request('POST', 'https://jsonplaceholder.cypress.io/posts', {
userId: user.id,
title: 'Cypress Test Runner',
body: 'Fast, easy and reliable testing for anything that runs in a browser.',
})
})
// note that the value here is the returned value of the 2nd request
// which is the new post object
.then((response) => {
expect(response).property('status').to.equal(201) // new entity created
expect(response).property('body').to.contain({
title: 'Cypress Test Runner',
})
// we don't know the exact post id - only that it will be > 100 let message = "whoa, this comment does not exist";
// since JSONPlaceholder has built-in 100 posts
expect(response.body).property('id').to.be.a('number')
.and.to.be.gt(100)
// we don't know the user id here - since it was in above closure // Listen to GET to comments/1
// so in this test just confirm that the property is there cy.intercept("GET", "**/comments/*").as("getComment");
expect(response.body).property('userId').to.be.a('number')
})
})
it('cy.request() - save response in the shared test context', () => { // we have code that gets a comment when
// https://on.cypress.io/variables-and-aliases // the button is clicked in scripts.js
cy.request('https://jsonplaceholder.cypress.io/users?_limit=1') cy.get(".network-btn").click();
.its('body').its('0') // yields the first element of the returned list
.as('user') // saves the object in the test context
.then(function () {
// NOTE 👀
// By the time this callback runs the "as('user')" command
// has saved the user object in the test context.
// To access the test context we need to use
// the "function () { ... }" callback form,
// otherwise "this" points at a wrong or undefined object!
cy.request('POST', 'https://jsonplaceholder.cypress.io/posts', {
userId: this.user.id,
title: 'Cypress Test Runner',
body: 'Fast, easy and reliable testing for anything that runs in a browser.',
})
.its('body').as('post') // save the new post from the response
})
.then(function () {
// When this callback runs, both "cy.request" API commands have finished
// and the test context has "user" and "post" objects set.
// Let's verify them.
expect(this.post, 'post has the right user id').property('userId').to.equal(this.user.id)
})
})
it('cy.intercept() - route responses to matching requests', () => { // https://on.cypress.io/wait
// https://on.cypress.io/intercept cy.wait("@getComment").its("response.statusCode").should("be.oneOf", [200, 304]);
let message = 'whoa, this comment does not exist' // Listen to POST to comments
cy.intercept("POST", "**/comments").as("postComment");
// Listen to GET to comments/1 // we have code that posts a comment when
cy.intercept('GET', '**/comments/*').as('getComment') // the button is clicked in scripts.js
cy.get(".network-post").click();
cy.wait("@postComment").should(({ request, response }) => {
expect(request.body).to.include("email");
expect(request.headers).to.have.property("content-type");
expect(response && response.body).to.have.property("name", "Using POST in cy.intercept()");
});
// we have code that gets a comment when // Stub a response to PUT comments/ ****
// the button is clicked in scripts.js cy.intercept(
cy.get('.network-btn').click() {
method: "PUT",
url: "**/comments/*"
},
{
statusCode: 404,
body: { error: message },
headers: { "access-control-allow-origin": "*" },
delayMs: 500
}
).as("putComment");
// https://on.cypress.io/wait // we have code that puts a comment when
cy.wait('@getComment').its('response.statusCode').should('be.oneOf', [200, 304]) // the button is clicked in scripts.js
cy.get(".network-put").click();
// Listen to POST to comments cy.wait("@putComment");
cy.intercept('POST', '**/comments').as('postComment')
// we have code that posts a comment when // our 404 statusCode logic in scripts.js executed
// the button is clicked in scripts.js cy.get(".network-put-comment").should("contain", message);
cy.get('.network-post').click() });
cy.wait('@postComment').should(({request, response}) => { });
expect(request.body).to.include('email')
expect(request.headers).to.have.property('content-type')
expect(response && response.body).to.have.property('name', 'Using POST in cy.intercept()')
})
// Stub a response to PUT comments/ ****
cy.intercept({
method: 'PUT',
url: '**/comments/*',
}, {
statusCode: 404,
body: {error: message},
headers: {'access-control-allow-origin': '*'},
delayMs: 500,
}).as('putComment')
// we have code that puts a comment when
// the button is clicked in scripts.js
cy.get('.network-put').click()
cy.wait('@putComment')
// our 404 statusCode logic in scripts.js executed
cy.get('.network-put-comment').should('contain', message)
})
})

View File

@@ -1,114 +1,100 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Querying', () => { context("Querying", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/querying') cy.visit("https://example.cypress.io/commands/querying");
}) });
// The most commonly used query is 'cy.get()', you can // The most commonly used query is 'cy.get()', you can
// think of this like the '$' in jQuery // think of this like the '$' in jQuery
it('cy.get() - query DOM elements', () => { it("cy.get() - query DOM elements", () => {
// https://on.cypress.io/get // https://on.cypress.io/get
cy.get('#query-btn').should('contain', 'Button') cy.get("#query-btn").should("contain", "Button");
cy.get('.query-btn').should('contain', 'Button') cy.get(".query-btn").should("contain", "Button");
cy.get('#querying .well>button:first').should('contain', 'Button') cy.get("#querying .well>button:first").should("contain", "Button");
// ↲ // ↲
// Use CSS selectors just like jQuery // Use CSS selectors just like jQuery
cy.get('[data-test-id="test-example"]').should('have.class', 'example') cy.get('[data-test-id="test-example"]').should("have.class", "example");
// 'cy.get()' yields jQuery object, you can get its attribute // 'cy.get()' yields jQuery object, you can get its attribute
// by invoking `.attr()` method // by invoking `.attr()` method
cy.get('[data-test-id="test-example"]') cy.get('[data-test-id="test-example"]').invoke("attr", "data-test-id").should("equal", "test-example");
.invoke('attr', 'data-test-id')
.should('equal', 'test-example')
// or you can get element's CSS property // or you can get element's CSS property
cy.get('[data-test-id="test-example"]') cy.get('[data-test-id="test-example"]').invoke("css", "position").should("equal", "static");
.invoke('css', 'position')
.should('equal', 'static')
// or use assertions directly during 'cy.get()' // or use assertions directly during 'cy.get()'
// https://on.cypress.io/assertions // https://on.cypress.io/assertions
cy.get('[data-test-id="test-example"]') cy.get('[data-test-id="test-example"]')
.should('have.attr', 'data-test-id', 'test-example') .should("have.attr", "data-test-id", "test-example")
.and('have.css', 'position', 'static') .and("have.css", "position", "static");
}) });
it('cy.contains() - query DOM elements with matching content', () => { it("cy.contains() - query DOM elements with matching content", () => {
// https://on.cypress.io/contains // https://on.cypress.io/contains
cy.get('.query-list') cy.get(".query-list").contains("bananas").should("have.class", "third");
.contains('bananas')
.should('have.class', 'third')
// we can pass a regexp to `.contains()` // we can pass a regexp to `.contains()`
cy.get('.query-list') cy.get(".query-list").contains(/^b\w+/).should("have.class", "third");
.contains(/^b\w+/)
.should('have.class', 'third')
cy.get('.query-list') cy.get(".query-list").contains("apples").should("have.class", "first");
.contains('apples')
.should('have.class', 'first')
// passing a selector to contains will // passing a selector to contains will
// yield the selector containing the text // yield the selector containing the text
cy.get('#querying') cy.get("#querying").contains("ul", "oranges").should("have.class", "query-list");
.contains('ul', 'oranges')
.should('have.class', 'query-list')
cy.get('.query-button') cy.get(".query-button").contains("Save Form").should("have.class", "btn");
.contains('Save Form') });
.should('have.class', 'btn')
})
it('.within() - query DOM elements within a specific element', () => { it(".within() - query DOM elements within a specific element", () => {
// https://on.cypress.io/within // https://on.cypress.io/within
cy.get('.query-form').within(() => { cy.get(".query-form").within(() => {
cy.get('input:first').should('have.attr', 'placeholder', 'Email') cy.get("input:first").should("have.attr", "placeholder", "Email");
cy.get('input:last').should('have.attr', 'placeholder', 'Password') cy.get("input:last").should("have.attr", "placeholder", "Password");
}) });
}) });
it('cy.root() - query the root DOM element', () => { it("cy.root() - query the root DOM element", () => {
// https://on.cypress.io/root // https://on.cypress.io/root
// By default, root is the document // By default, root is the document
cy.root().should('match', 'html') cy.root().should("match", "html");
cy.get('.query-ul').within(() => { cy.get(".query-ul").within(() => {
// In this within, the root is now the ul DOM element // In this within, the root is now the ul DOM element
cy.root().should('have.class', 'query-ul') cy.root().should("have.class", "query-ul");
}) });
}) });
it('best practices - selecting elements', () => { it("best practices - selecting elements", () => {
// https://on.cypress.io/best-practices#Selecting-Elements // https://on.cypress.io/best-practices#Selecting-Elements
cy.get('[data-cy=best-practices-selecting-elements]').within(() => { cy.get("[data-cy=best-practices-selecting-elements]").within(() => {
// Worst - too generic, no context // Worst - too generic, no context
cy.get('button').click() cy.get("button").click();
// Bad. Coupled to styling. Highly subject to change. // Bad. Coupled to styling. Highly subject to change.
cy.get('.btn.btn-large').click() cy.get(".btn.btn-large").click();
// Average. Coupled to the `name` attribute which has HTML semantics. // Average. Coupled to the `name` attribute which has HTML semantics.
cy.get('[name=submission]').click() cy.get("[name=submission]").click();
// Better. But still coupled to styling or JS event listeners. // Better. But still coupled to styling or JS event listeners.
cy.get('#main').click() cy.get("#main").click();
// Slightly better. Uses an ID but also ensures the element // Slightly better. Uses an ID but also ensures the element
// has an ARIA role attribute // has an ARIA role attribute
cy.get('#main[role=button]').click() cy.get("#main[role=button]").click();
// Much better. But still coupled to text content that may change. // Much better. But still coupled to text content that may change.
cy.contains('Submit').click() cy.contains("Submit").click();
// Best. Insulated from all changes. // Best. Insulated from all changes.
cy.get('[data-cy=submit]').click() cy.get("[data-cy=submit]").click();
}) });
}) });
}) });

View File

@@ -2,205 +2,202 @@
// remove no check once Cypress.sinon is typed // remove no check once Cypress.sinon is typed
// https://github.com/cypress-io/cypress/issues/6720 // https://github.com/cypress-io/cypress/issues/6720
context('Spies, Stubs, and Clock', () => { context("Spies, Stubs, and Clock", () => {
it('cy.spy() - wrap a method in a spy', () => { it("cy.spy() - wrap a method in a spy", () => {
// https://on.cypress.io/spy // https://on.cypress.io/spy
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = { const obj = {
foo() { foo() {}
}, };
}
const spy = cy.spy(obj, 'foo').as('anyArgs') const spy = cy.spy(obj, "foo").as("anyArgs");
obj.foo() obj.foo();
expect(spy).to.be.called expect(spy).to.be.called;
}) });
it('cy.spy() retries until assertions pass', () => { it("cy.spy() retries until assertions pass", () => {
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = { const obj = {
/** /**
* Prints the argument passed * Prints the argument passed
* @param x {any} * @param x {any}
*/ */
foo(x) { foo(x) {
console.log('obj.foo called with', x) console.log("obj.foo called with", x);
}, }
} };
cy.spy(obj, 'foo').as('foo') cy.spy(obj, "foo").as("foo");
setTimeout(() => { setTimeout(() => {
obj.foo('first') obj.foo("first");
}, 500) }, 500);
setTimeout(() => { setTimeout(() => {
obj.foo('second') obj.foo("second");
}, 2500) }, 2500);
cy.get('@foo').should('have.been.calledTwice') cy.get("@foo").should("have.been.calledTwice");
}) });
it('cy.stub() - create a stub and/or replace a function with stub', () => { it("cy.stub() - create a stub and/or replace a function with stub", () => {
// https://on.cypress.io/stub // https://on.cypress.io/stub
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
const obj = { const obj = {
/** /**
* prints both arguments to the console * prints both arguments to the console
* @param a {string} * @param a {string}
* @param b {string} * @param b {string}
*/ */
foo(a, b) { foo(a, b) {
console.log('a', a, 'b', b) console.log("a", a, "b", b);
}, }
} };
const stub = cy.stub(obj, 'foo').as('foo') const stub = cy.stub(obj, "foo").as("foo");
obj.foo('foo', 'bar') obj.foo("foo", "bar");
expect(stub).to.be.called expect(stub).to.be.called;
}) });
it('cy.clock() - control time in the browser', () => { it("cy.clock() - control time in the browser", () => {
// https://on.cypress.io/clock // https://on.cypress.io/clock
// create the date in UTC so its always the same // create the date in UTC so its always the same
// no matter what local timezone the browser is running in // no matter what local timezone the browser is running in
const now = new Date(Date.UTC(2017, 2, 14)).getTime() const now = new Date(Date.UTC(2017, 2, 14)).getTime();
cy.clock(now) cy.clock(now);
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
cy.get('#clock-div').click() cy.get("#clock-div").click().should("have.text", "1489449600");
.should('have.text', '1489449600') });
})
it('cy.tick() - move time in the browser', () => { it("cy.tick() - move time in the browser", () => {
// https://on.cypress.io/tick // https://on.cypress.io/tick
// create the date in UTC so its always the same // create the date in UTC so its always the same
// no matter what local timezone the browser is running in // no matter what local timezone the browser is running in
const now = new Date(Date.UTC(2017, 2, 14)).getTime() const now = new Date(Date.UTC(2017, 2, 14)).getTime();
cy.clock(now) cy.clock(now);
cy.visit('https://example.cypress.io/commands/spies-stubs-clocks') cy.visit("https://example.cypress.io/commands/spies-stubs-clocks");
cy.get('#tick-div').click() cy.get("#tick-div").click().should("have.text", "1489449600");
.should('have.text', '1489449600')
cy.tick(10000) // 10 seconds passed cy.tick(10000); // 10 seconds passed
cy.get('#tick-div').click() cy.get("#tick-div").click().should("have.text", "1489449610");
.should('have.text', '1489449610') });
})
it('cy.stub() matches depending on arguments', () => { it("cy.stub() matches depending on arguments", () => {
// see all possible matchers at // see all possible matchers at
// https://sinonjs.org/releases/latest/matchers/ // https://sinonjs.org/releases/latest/matchers/
const greeter = { const greeter = {
/** /**
* Greets a person * Greets a person
* @param {string} name * @param {string} name
*/ */
greet(name) { greet(name) {
return `Hello, ${name}!` return `Hello, ${name}!`;
}, }
} };
cy.stub(greeter, 'greet') cy.stub(greeter, "greet")
.callThrough() // if you want non-matched calls to call the real method .callThrough() // if you want non-matched calls to call the real method
.withArgs(Cypress.sinon.match.string).returns('Hi') .withArgs(Cypress.sinon.match.string)
.withArgs(Cypress.sinon.match.number).throws(new Error('Invalid name')) .returns("Hi")
.withArgs(Cypress.sinon.match.number)
.throws(new Error("Invalid name"));
expect(greeter.greet('World')).to.equal('Hi') expect(greeter.greet("World")).to.equal("Hi");
// @ts-ignore // @ts-ignore
expect(() => greeter.greet(42)).to.throw('Invalid name') expect(() => greeter.greet(42)).to.throw("Invalid name");
expect(greeter.greet).to.have.been.calledTwice expect(greeter.greet).to.have.been.calledTwice;
// non-matched calls goes the actual method // non-matched calls goes the actual method
// @ts-ignore // @ts-ignore
expect(greeter.greet()).to.equal('Hello, undefined!') expect(greeter.greet()).to.equal("Hello, undefined!");
}) });
it('matches call arguments using Sinon matchers', () => { it("matches call arguments using Sinon matchers", () => {
// see all possible matchers at // see all possible matchers at
// https://sinonjs.org/releases/latest/matchers/ // https://sinonjs.org/releases/latest/matchers/
const calculator = { const calculator = {
/** /**
* returns the sum of two arguments * returns the sum of two arguments
* @param a {number} * @param a {number}
* @param b {number} * @param b {number}
*/ */
add(a, b) { add(a, b) {
return a + b return a + b;
}, }
} };
const spy = cy.spy(calculator, 'add').as('add') const spy = cy.spy(calculator, "add").as("add");
expect(calculator.add(2, 3)).to.equal(5) expect(calculator.add(2, 3)).to.equal(5);
// if we want to assert the exact values used during the call // if we want to assert the exact values used during the call
expect(spy).to.be.calledWith(2, 3) expect(spy).to.be.calledWith(2, 3);
// let's confirm "add" method was called with two numbers // let's confirm "add" method was called with two numbers
expect(spy).to.be.calledWith(Cypress.sinon.match.number, Cypress.sinon.match.number) expect(spy).to.be.calledWith(Cypress.sinon.match.number, Cypress.sinon.match.number);
// alternatively, provide the value to match // alternatively, provide the value to match
expect(spy).to.be.calledWith(Cypress.sinon.match(2), Cypress.sinon.match(3)) expect(spy).to.be.calledWith(Cypress.sinon.match(2), Cypress.sinon.match(3));
// match any value // match any value
expect(spy).to.be.calledWith(Cypress.sinon.match.any, 3) expect(spy).to.be.calledWith(Cypress.sinon.match.any, 3);
// match any value from a list // match any value from a list
expect(spy).to.be.calledWith(Cypress.sinon.match.in([1, 2, 3]), 3) expect(spy).to.be.calledWith(Cypress.sinon.match.in([1, 2, 3]), 3);
/** /**
* Returns true if the given number is event * Returns true if the given number is event
* @param {number} x * @param {number} x
*/ */
const isEven = (x) => x % 2 === 0 const isEven = (x) => x % 2 === 0;
// expect the value to pass a custom predicate function // expect the value to pass a custom predicate function
// the second argument to "sinon.match(predicate, message)" is // the second argument to "sinon.match(predicate, message)" is
// shown if the predicate does not pass and assertion fails // shown if the predicate does not pass and assertion fails
expect(spy).to.be.calledWith(Cypress.sinon.match(isEven, 'isEven'), 3) expect(spy).to.be.calledWith(Cypress.sinon.match(isEven, "isEven"), 3);
/** /**
* Returns a function that checks if a given number is larger than the limit * Returns a function that checks if a given number is larger than the limit
* @param {number} limit * @param {number} limit
* @returns {(x: number) => boolean} * @returns {(x: number) => boolean}
*/ */
const isGreaterThan = (limit) => (x) => x > limit const isGreaterThan = (limit) => (x) => x > limit;
/** /**
* Returns a function that checks if a given number is less than the limit * Returns a function that checks if a given number is less than the limit
* @param {number} limit * @param {number} limit
* @returns {(x: number) => boolean} * @returns {(x: number) => boolean}
*/ */
const isLessThan = (limit) => (x) => x < limit const isLessThan = (limit) => (x) => x < limit;
// you can combine several matchers using "and", "or" // you can combine several matchers using "and", "or"
expect(spy).to.be.calledWith( expect(spy).to.be.calledWith(
Cypress.sinon.match.number, Cypress.sinon.match.number,
Cypress.sinon.match(isGreaterThan(2), '> 2').and(Cypress.sinon.match(isLessThan(4), '< 4')), Cypress.sinon.match(isGreaterThan(2), "> 2").and(Cypress.sinon.match(isLessThan(4), "< 4"))
) );
expect(spy).to.be.calledWith( expect(spy).to.be.calledWith(
Cypress.sinon.match.number, Cypress.sinon.match.number,
Cypress.sinon.match(isGreaterThan(200), '> 200').or(Cypress.sinon.match(3)), Cypress.sinon.match(isGreaterThan(200), "> 200").or(Cypress.sinon.match(3))
) );
// matchers can be used from BDD assertions // matchers can be used from BDD assertions
cy.get('@add').should('have.been.calledWith', cy.get("@add").should("have.been.calledWith", Cypress.sinon.match.number, Cypress.sinon.match(3));
Cypress.sinon.match.number, Cypress.sinon.match(3))
// you can alias matchers for shorter test code // you can alias matchers for shorter test code
const {match: M} = Cypress.sinon const { match: M } = Cypress.sinon;
cy.get('@add').should('have.been.calledWith', M.number, M(3)) cy.get("@add").should("have.been.calledWith", M.number, M(3));
}) });
}) });

View File

@@ -1,121 +1,97 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Traversal', () => { context("Traversal", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/traversal') cy.visit("https://example.cypress.io/commands/traversal");
}) });
it('.children() - get child DOM elements', () => { it(".children() - get child DOM elements", () => {
// https://on.cypress.io/children // https://on.cypress.io/children
cy.get('.traversal-breadcrumb') cy.get(".traversal-breadcrumb").children(".active").should("contain", "Data");
.children('.active') });
.should('contain', 'Data')
})
it('.closest() - get closest ancestor DOM element', () => { it(".closest() - get closest ancestor DOM element", () => {
// https://on.cypress.io/closest // https://on.cypress.io/closest
cy.get('.traversal-badge') cy.get(".traversal-badge").closest("ul").should("have.class", "list-group");
.closest('ul') });
.should('have.class', 'list-group')
})
it('.eq() - get a DOM element at a specific index', () => { it(".eq() - get a DOM element at a specific index", () => {
// https://on.cypress.io/eq // https://on.cypress.io/eq
cy.get('.traversal-list>li') cy.get(".traversal-list>li").eq(1).should("contain", "siamese");
.eq(1).should('contain', 'siamese') });
})
it('.filter() - get DOM elements that match the selector', () => { it(".filter() - get DOM elements that match the selector", () => {
// https://on.cypress.io/filter // https://on.cypress.io/filter
cy.get('.traversal-nav>li') cy.get(".traversal-nav>li").filter(".active").should("contain", "About");
.filter('.active').should('contain', 'About') });
})
it('.find() - get descendant DOM elements of the selector', () => { it(".find() - get descendant DOM elements of the selector", () => {
// https://on.cypress.io/find // https://on.cypress.io/find
cy.get('.traversal-pagination') cy.get(".traversal-pagination").find("li").find("a").should("have.length", 7);
.find('li').find('a') });
.should('have.length', 7)
})
it('.first() - get first DOM element', () => { it(".first() - get first DOM element", () => {
// https://on.cypress.io/first // https://on.cypress.io/first
cy.get('.traversal-table td') cy.get(".traversal-table td").first().should("contain", "1");
.first().should('contain', '1') });
})
it('.last() - get last DOM element', () => { it(".last() - get last DOM element", () => {
// https://on.cypress.io/last // https://on.cypress.io/last
cy.get('.traversal-buttons .btn') cy.get(".traversal-buttons .btn").last().should("contain", "Submit");
.last().should('contain', 'Submit') });
})
it('.next() - get next sibling DOM element', () => { it(".next() - get next sibling DOM element", () => {
// https://on.cypress.io/next // https://on.cypress.io/next
cy.get('.traversal-ul') cy.get(".traversal-ul").contains("apples").next().should("contain", "oranges");
.contains('apples').next().should('contain', 'oranges') });
})
it('.nextAll() - get all next sibling DOM elements', () => { it(".nextAll() - get all next sibling DOM elements", () => {
// https://on.cypress.io/nextall // https://on.cypress.io/nextall
cy.get('.traversal-next-all') cy.get(".traversal-next-all").contains("oranges").nextAll().should("have.length", 3);
.contains('oranges') });
.nextAll().should('have.length', 3)
})
it('.nextUntil() - get next sibling DOM elements until next el', () => { it(".nextUntil() - get next sibling DOM elements until next el", () => {
// https://on.cypress.io/nextuntil // https://on.cypress.io/nextuntil
cy.get('#veggies') cy.get("#veggies").nextUntil("#nuts").should("have.length", 3);
.nextUntil('#nuts').should('have.length', 3) });
})
it('.not() - remove DOM elements from set of DOM elements', () => { it(".not() - remove DOM elements from set of DOM elements", () => {
// https://on.cypress.io/not // https://on.cypress.io/not
cy.get('.traversal-disabled .btn') cy.get(".traversal-disabled .btn").not("[disabled]").should("not.contain", "Disabled");
.not('[disabled]').should('not.contain', 'Disabled') });
})
it('.parent() - get parent DOM element from DOM elements', () => { it(".parent() - get parent DOM element from DOM elements", () => {
// https://on.cypress.io/parent // https://on.cypress.io/parent
cy.get('.traversal-mark') cy.get(".traversal-mark").parent().should("contain", "Morbi leo risus");
.parent().should('contain', 'Morbi leo risus') });
})
it('.parents() - get parent DOM elements from DOM elements', () => { it(".parents() - get parent DOM elements from DOM elements", () => {
// https://on.cypress.io/parents // https://on.cypress.io/parents
cy.get('.traversal-cite') cy.get(".traversal-cite").parents().should("match", "blockquote");
.parents().should('match', 'blockquote') });
})
it('.parentsUntil() - get parent DOM elements from DOM elements until el', () => { it(".parentsUntil() - get parent DOM elements from DOM elements until el", () => {
// https://on.cypress.io/parentsuntil // https://on.cypress.io/parentsuntil
cy.get('.clothes-nav') cy.get(".clothes-nav").find(".active").parentsUntil(".clothes-nav").should("have.length", 2);
.find('.active') });
.parentsUntil('.clothes-nav')
.should('have.length', 2)
})
it('.prev() - get previous sibling DOM element', () => { it(".prev() - get previous sibling DOM element", () => {
// https://on.cypress.io/prev // https://on.cypress.io/prev
cy.get('.birds').find('.active') cy.get(".birds").find(".active").prev().should("contain", "Lorikeets");
.prev().should('contain', 'Lorikeets') });
})
it('.prevAll() - get all previous sibling DOM elements', () => { it(".prevAll() - get all previous sibling DOM elements", () => {
// https://on.cypress.io/prevall // https://on.cypress.io/prevall
cy.get('.fruits-list').find('.third') cy.get(".fruits-list").find(".third").prevAll().should("have.length", 2);
.prevAll().should('have.length', 2) });
})
it('.prevUntil() - get all previous sibling DOM elements until el', () => { it(".prevUntil() - get all previous sibling DOM elements until el", () => {
// https://on.cypress.io/prevuntil // https://on.cypress.io/prevuntil
cy.get('.foods-list').find('#nuts') cy.get(".foods-list").find("#nuts").prevUntil("#veggies").should("have.length", 3);
.prevUntil('#veggies').should('have.length', 3) });
})
it('.siblings() - get all sibling DOM elements', () => { it(".siblings() - get all sibling DOM elements", () => {
// https://on.cypress.io/siblings // https://on.cypress.io/siblings
cy.get('.traversal-pills .active') cy.get(".traversal-pills .active").siblings().should("have.length", 2);
.siblings().should('have.length', 2) });
}) });
})

View File

@@ -1,110 +1,108 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Utilities', () => { context("Utilities", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/utilities') cy.visit("https://example.cypress.io/utilities");
}) });
it('Cypress._ - call a lodash method', () => { it("Cypress._ - call a lodash method", () => {
// https://on.cypress.io/_ // https://on.cypress.io/_
cy.request('https://jsonplaceholder.cypress.io/users') cy.request("https://jsonplaceholder.cypress.io/users").then((response) => {
.then((response) => { let ids = Cypress._.chain(response.body).map("id").take(3).value();
let ids = Cypress._.chain(response.body).map('id').take(3).value()
expect(ids).to.deep.eq([1, 2, 3]) expect(ids).to.deep.eq([1, 2, 3]);
}) });
}) });
it('Cypress.$ - call a jQuery method', () => { it("Cypress.$ - call a jQuery method", () => {
// https://on.cypress.io/$ // https://on.cypress.io/$
let $li = Cypress.$('.utility-jquery li:first') let $li = Cypress.$(".utility-jquery li:first");
cy.wrap($li) cy.wrap($li).should("not.have.class", "active").click().should("have.class", "active");
.should('not.have.class', 'active') });
.click()
.should('have.class', 'active')
})
it('Cypress.Blob - blob utilities and base64 string conversion', () => { it("Cypress.Blob - blob utilities and base64 string conversion", () => {
// https://on.cypress.io/blob // https://on.cypress.io/blob
cy.get('.utility-blob').then(($div) => { cy.get(".utility-blob").then(($div) => {
// https://github.com/nolanlawson/blob-util#imgSrcToDataURL // https://github.com/nolanlawson/blob-util#imgSrcToDataURL
// get the dataUrl string for the javascript-logo // get the dataUrl string for the javascript-logo
return Cypress.Blob.imgSrcToDataURL('https://example.cypress.io/assets/img/javascript-logo.png', undefined, 'anonymous') return Cypress.Blob.imgSrcToDataURL(
.then((dataUrl) => { "https://example.cypress.io/assets/img/javascript-logo.png",
// create an <img> element and set its src to the dataUrl undefined,
let img = Cypress.$('<img />', {src: dataUrl}) "anonymous"
).then((dataUrl) => {
// create an <img> element and set its src to the dataUrl
let img = Cypress.$("<img />", { src: dataUrl });
// need to explicitly return cy here since we are initially returning // need to explicitly return cy here since we are initially returning
// the Cypress.Blob.imgSrcToDataURL promise to our test // the Cypress.Blob.imgSrcToDataURL promise to our test
// append the image // append the image
$div.append(img) $div.append(img);
cy.get('.utility-blob img').click() cy.get(".utility-blob img").click().should("have.attr", "src", dataUrl);
.should('have.attr', 'src', dataUrl) });
}) });
}) });
})
it('Cypress.minimatch - test out glob patterns against strings', () => { it("Cypress.minimatch - test out glob patterns against strings", () => {
// https://on.cypress.io/minimatch // https://on.cypress.io/minimatch
let matching = Cypress.minimatch('/users/1/comments', '/users/*/comments', { let matching = Cypress.minimatch("/users/1/comments", "/users/*/comments", {
matchBase: true, matchBase: true
}) });
expect(matching, 'matching wildcard').to.be.true expect(matching, "matching wildcard").to.be.true;
matching = Cypress.minimatch('/users/1/comments/2', '/users/*/comments', { matching = Cypress.minimatch("/users/1/comments/2", "/users/*/comments", {
matchBase: true, matchBase: true
}) });
expect(matching, 'comments').to.be.false expect(matching, "comments").to.be.false;
// ** matches against all downstream path segments // ** matches against all downstream path segments
matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/**', { matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/**", {
matchBase: true, matchBase: true
}) });
expect(matching, 'comments').to.be.true expect(matching, "comments").to.be.true;
// whereas * matches only the next path segment // whereas * matches only the next path segment
matching = Cypress.minimatch('/foo/bar/baz/123/quux?a=b&c=2', '/foo/*', { matching = Cypress.minimatch("/foo/bar/baz/123/quux?a=b&c=2", "/foo/*", {
matchBase: false, matchBase: false
}) });
expect(matching, 'comments').to.be.false expect(matching, "comments").to.be.false;
}) });
it('Cypress.Promise - instantiate a bluebird promise', () => { it("Cypress.Promise - instantiate a bluebird promise", () => {
// https://on.cypress.io/promise // https://on.cypress.io/promise
let waited = false let waited = false;
/** /**
* @return Bluebird<string> * @return Bluebird<string>
*/ */
function waitOneSecond() { function waitOneSecond() {
// return a promise that resolves after 1 second // return a promise that resolves after 1 second
// @ts-ignore TS2351 (new Cypress.Promise) // @ts-ignore TS2351 (new Cypress.Promise)
return new Cypress.Promise((resolve, reject) => { return new Cypress.Promise((resolve, reject) => {
setTimeout(() => { setTimeout(() => {
// set waited to true // set waited to true
waited = true waited = true;
// resolve with 'foo' string // resolve with 'foo' string
resolve('foo') resolve("foo");
}, 1000) }, 1000);
}) });
} }
cy.then(() => { cy.then(() => {
// return a promise to cy.then() that // return a promise to cy.then() that
// is awaited until it resolves // is awaited until it resolves
// @ts-ignore TS7006 // @ts-ignore TS7006
return waitOneSecond().then((str) => { return waitOneSecond().then((str) => {
expect(str).to.eq('foo') expect(str).to.eq("foo");
expect(waited).to.be.true expect(waited).to.be.true;
}) });
}) });
}) });
}) });

View File

@@ -1,59 +1,59 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Viewport', () => { context("Viewport", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/viewport') cy.visit("https://example.cypress.io/commands/viewport");
}) });
it('cy.viewport() - set the viewport size and dimension', () => { it("cy.viewport() - set the viewport size and dimension", () => {
// https://on.cypress.io/viewport // https://on.cypress.io/viewport
cy.get('#navbar').should('be.visible') cy.get("#navbar").should("be.visible");
cy.viewport(320, 480) cy.viewport(320, 480);
// the navbar should have collapse since our screen is smaller // the navbar should have collapse since our screen is smaller
cy.get('#navbar').should('not.be.visible') cy.get("#navbar").should("not.be.visible");
cy.get('.navbar-toggle').should('be.visible').click() cy.get(".navbar-toggle").should("be.visible").click();
cy.get('.nav').find('a').should('be.visible') cy.get(".nav").find("a").should("be.visible");
// lets see what our app looks like on a super large screen // lets see what our app looks like on a super large screen
cy.viewport(2999, 2999) cy.viewport(2999, 2999);
// cy.viewport() accepts a set of preset sizes // cy.viewport() accepts a set of preset sizes
// to easily set the screen to a device's width and height // to easily set the screen to a device's width and height
// We added a cy.wait() between each viewport change so you can see // We added a cy.wait() between each viewport change so you can see
// the change otherwise it is a little too fast to see :) // the change otherwise it is a little too fast to see :)
cy.viewport('macbook-15') cy.viewport("macbook-15");
cy.wait(200) cy.wait(200);
cy.viewport('macbook-13') cy.viewport("macbook-13");
cy.wait(200) cy.wait(200);
cy.viewport('macbook-11') cy.viewport("macbook-11");
cy.wait(200) cy.wait(200);
cy.viewport('ipad-2') cy.viewport("ipad-2");
cy.wait(200) cy.wait(200);
cy.viewport('ipad-mini') cy.viewport("ipad-mini");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-6+') cy.viewport("iphone-6+");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-6') cy.viewport("iphone-6");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-5') cy.viewport("iphone-5");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-4') cy.viewport("iphone-4");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-3') cy.viewport("iphone-3");
cy.wait(200) cy.wait(200);
// cy.viewport() accepts an orientation for all presets // cy.viewport() accepts an orientation for all presets
// the default orientation is 'portrait' // the default orientation is 'portrait'
cy.viewport('ipad-2', 'portrait') cy.viewport("ipad-2", "portrait");
cy.wait(200) cy.wait(200);
cy.viewport('iphone-4', 'landscape') cy.viewport("iphone-4", "landscape");
cy.wait(200) cy.wait(200);
// The viewport will be reset back to the default dimensions // The viewport will be reset back to the default dimensions
// in between tests (the default can be set in cypress.json) // in between tests (the default can be set in cypress.json)
}) });
}) });

View File

@@ -1,31 +1,31 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Waiting', () => { context("Waiting", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/waiting') cy.visit("https://example.cypress.io/commands/waiting");
}) });
// BE CAREFUL of adding unnecessary wait times. // BE CAREFUL of adding unnecessary wait times.
// https://on.cypress.io/best-practices#Unnecessary-Waiting // https://on.cypress.io/best-practices#Unnecessary-Waiting
// https://on.cypress.io/wait // https://on.cypress.io/wait
it('cy.wait() - wait for a specific amount of time', () => { it("cy.wait() - wait for a specific amount of time", () => {
cy.get('.wait-input1').type('Wait 1000ms after typing') cy.get(".wait-input1").type("Wait 1000ms after typing");
cy.wait(1000) cy.wait(1000);
cy.get('.wait-input2').type('Wait 1000ms after typing') cy.get(".wait-input2").type("Wait 1000ms after typing");
cy.wait(1000) cy.wait(1000);
cy.get('.wait-input3').type('Wait 1000ms after typing') cy.get(".wait-input3").type("Wait 1000ms after typing");
cy.wait(1000) cy.wait(1000);
}) });
it('cy.wait() - wait for a specific route', () => { it("cy.wait() - wait for a specific route", () => {
// Listen to GET to comments/1 // Listen to GET to comments/1
cy.intercept('GET', '**/comments/*').as('getComment') cy.intercept("GET", "**/comments/*").as("getComment");
// we have code that gets a comment when // we have code that gets a comment when
// the button is clicked in scripts.js // the button is clicked in scripts.js
cy.get('.network-btn').click() cy.get(".network-btn").click();
// wait for GET comments/1 // wait for GET comments/1
cy.wait('@getComment').its('response.statusCode').should('be.oneOf', [200, 304]) cy.wait("@getComment").its("response.statusCode").should("be.oneOf", [200, 304]);
}) });
}) });

View File

@@ -1,22 +1,22 @@
/// <reference types="cypress" /> /// <reference types="cypress" />
context('Window', () => { context("Window", () => {
beforeEach(() => { beforeEach(() => {
cy.visit('https://example.cypress.io/commands/window') cy.visit("https://example.cypress.io/commands/window");
}) });
it('cy.window() - get the global window object', () => { it("cy.window() - get the global window object", () => {
// https://on.cypress.io/window // https://on.cypress.io/window
cy.window().should('have.property', 'top') cy.window().should("have.property", "top");
}) });
it('cy.document() - get the document object', () => { it("cy.document() - get the document object", () => {
// https://on.cypress.io/document // https://on.cypress.io/document
cy.document().should('have.property', 'charset').and('eq', 'UTF-8') cy.document().should("have.property", "charset").and("eq", "UTF-8");
}) });
it('cy.title() - get the title', () => { it("cy.title() - get the title", () => {
// https://on.cypress.io/title // https://on.cypress.io/title
cy.title().should('include', 'Kitchen Sink') cy.title().should("include", "Kitchen Sink");
}) });
}) });

View File

@@ -2,4 +2,4 @@
"id": 8739, "id": 8739,
"name": "Jane", "name": "Jane",
"email": "jane@example.com" "email": "jane@example.com"
} }

View File

@@ -17,6 +17,6 @@
*/ */
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
module.exports = (on, config) => { module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits // `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config // `config` is the resolved Cypress config
} };

View File

@@ -14,7 +14,7 @@
// *********************************************************** // ***********************************************************
// Import commands.js using ES2015 syntax: // Import commands.js using ES2015 syntax:
import './commands' import "./commands";
// Alternatively you can use CommonJS syntax: // Alternatively you can use CommonJS syntax:
// require('./commands') // require('./commands')

View File

@@ -2,11 +2,7 @@
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"baseUrl": "../node_modules", "baseUrl": "../node_modules",
"types": [ "types": ["cypress"]
"cypress"
]
}, },
"include": [ "include": ["**/*.*"]
"**/*.*"
]
} }

View File

@@ -1 +1,2 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' }) if ("serviceWorker" in navigator)
navigator.serviceWorker.register("/dev-sw.js?dev-sw", { scope: "/", type: "classic" });

View File

@@ -21,22 +21,20 @@ if (!self.define) {
const singleRequire = (uri, parentUri) => { const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href; uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || ( return (
registry[uri] ||
new Promise(resolve => { new Promise((resolve) => {
if ("document" in self) { if ("document" in self) {
const script = document.createElement("script"); const script = document.createElement("script");
script.src = uri; script.src = uri;
script.onload = resolve; script.onload = resolve;
document.head.appendChild(script); document.head.appendChild(script);
} else { } else {
nextDefineUri = uri; nextDefineUri = uri;
importScripts(uri); importScripts(uri);
resolve(); resolve();
} }
}) }).then(() => {
.then(() => {
let promise = registry[uri]; let promise = registry[uri];
if (!promise) { if (!promise) {
throw new Error(`Module ${uri} didnt register its module`); throw new Error(`Module ${uri} didnt register its module`);
@@ -53,21 +51,20 @@ if (!self.define) {
return; return;
} }
let exports = {}; let exports = {};
const require = depUri => singleRequire(depUri, uri); const require = (depUri) => singleRequire(depUri, uri);
const specialDeps = { const specialDeps = {
module: { uri }, module: { uri },
exports, exports,
require require
}; };
registry[uri] = Promise.all(depsNames.map( registry[uri] = Promise.all(depsNames.map((depName) => specialDeps[depName] || require(depName))).then((deps) => {
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps); factory(...deps);
return exports; return exports;
}); });
}; };
} }
define(['./workbox-b5f7729d'], (function (workbox) { 'use strict'; define(["./workbox-b5f7729d"], function (workbox) {
"use strict";
self.skipWaiting(); self.skipWaiting();
workbox.clientsClaim(); workbox.clientsClaim();
@@ -77,16 +74,23 @@ define(['./workbox-b5f7729d'], (function (workbox) { 'use strict';
* requests for URLs in the manifest. * requests for URLs in the manifest.
* See https://goo.gl/S9QRab * See https://goo.gl/S9QRab
*/ */
workbox.precacheAndRoute([{ workbox.precacheAndRoute(
"url": "registerSW.js", [
"revision": "3ca0b8505b4bec776b69afdba2768812" {
}, { url: "registerSW.js",
"url": "index.html", revision: "3ca0b8505b4bec776b69afdba2768812"
"revision": "0.sa702m4aq68" },
}], {}); {
url: "index.html",
revision: "0.sa702m4aq68"
}
],
{}
);
workbox.cleanupOutdatedCaches(); workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { workbox.registerRoute(
allowlist: [/^\/$/] new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
})); allowlist: [/^\/$/]
})
})); );
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,58 @@
import {ApolloProvider} from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import {SplitFactoryProvider, SplitSdk,} from '@splitsoftware/splitio-react'; import { SplitFactoryProvider, 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 dayjs from "../utils/day"; import dayjs from "../utils/day";
import 'dayjs/locale/en'; 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";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import themeProvider from "./themeProvider"; import themeProvider from "./themeProvider";
import { Userpilot } from 'userpilot' import { Userpilot } from "userpilot";
// Initialize Userpilot // Initialize Userpilot
if(import.meta.env.DEV){ if (import.meta.env.DEV) {
Userpilot.initialize('NX-69145f08'); Userpilot.initialize("NX-69145f08");
} }
dayjs.locale("en"); dayjs.locale("en");
const config = { const config = {
core: { core: {
authorizationKey: import.meta.env.VITE_APP_SPLIT_API, authorizationKey: import.meta.env.VITE_APP_SPLIT_API,
key: "anon", key: "anon"
}, }
}; };
export const factory = SplitSdk(config); export const factory = SplitSdk(config);
function AppContainer() { function AppContainer() {
const {t} = useTranslation(); const { t } = useTranslation();
return ( return (
<ApolloProvider client={client}> <ApolloProvider client={client}>
<ConfigProvider <ConfigProvider
//componentSize="small" //componentSize="small"
input={{autoComplete: "new-password"}} input={{ autoComplete: "new-password" }}
locale={enLocale} locale={enLocale}
theme={themeProvider} theme={themeProvider}
form={{ form={{
validateMessages: { validateMessages: {
// eslint-disable-next-line no-template-curly-in-string // eslint-disable-next-line no-template-curly-in-string
required: t("general.validation.required", {label: "${label}"}), required: t("general.validation.required", { label: "${label}" })
}, }
}} }}
> >
<GlobalLoadingBar/> <GlobalLoadingBar />
<SplitFactoryProvider factory={factory}> <SplitFactoryProvider factory={factory}>
<App/> <App />
</SplitFactoryProvider> </SplitFactoryProvider>
</ConfigProvider> </ConfigProvider>
</ApolloProvider> </ApolloProvider>
); );
} }
export default Sentry.withProfiler(AppContainer); export default Sentry.withProfiler(AppContainer);

View File

@@ -17,245 +17,225 @@ 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, selectCurrentEula, selectCurrentUser } from "../redux/user/user.selectors";
selectBodyshop,
selectCurrentEula,
selectCurrentUser,
} from "../redux/user/user.selectors";
import PrivateRoute from "../components/PrivateRoute"; import PrivateRoute from "../components/PrivateRoute";
import "./App.styles.scss"; import "./App.styles.scss";
import handleBeta from "../utils/betaHandler"; import handleBeta from "../utils/betaHandler";
import Eula from "../components/eula/eula.component"; import Eula from "../components/eula/eula.component";
import InstanceRenderMgr from "../utils/instanceRenderMgr"; import InstanceRenderMgr from "../utils/instanceRenderMgr";
import { ProductFruits } from 'react-product-fruits'; import { ProductFruits } from "react-product-fruits";
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,
currentEula: selectCurrentEula, currentEula: selectCurrentEula
}); });
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, currentEula }) {
bodyshop, const client = useSplitClient().client;
checkUserSession, const [listenersAdded, setListenersAdded] = useState(false);
currentUser, const { t } = useTranslation();
online,
setOnline,
currentEula,
}) {
const client = useSplitClient().client;
const [listenersAdded, setListenersAdded] = useState(false);
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
if (!navigator.onLine) { if (!navigator.onLine) {
setOnline(false); setOnline(false);
}
checkUserSession();
}, [checkUserSession, setOnline]);
//const b = Grid.useBreakpoint();
// console.log("Breakpoints:", b);
// Associate event listeners, memoize to prevent multiple listeners being added
useEffect(() => {
const offlineListener = (e) => {
setOnline(false);
};
const onlineListener = (e) => {
setOnline(true);
};
if (!listenersAdded) {
console.log("Added events for offline and online");
window.addEventListener("offline", offlineListener);
window.addEventListener("online", onlineListener);
setListenersAdded(true);
}
return () => {
window.removeEventListener("offline", offlineListener);
window.removeEventListener("online", onlineListener);
};
}, [setOnline, listenersAdded]);
useEffect(() => {
if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (
client.getTreatment("LogRocket_Tracking") === "on" ||
window.location.hostname ===
InstanceRenderMgr({
imex: "beta.imex.online",
rome: "beta.romeonline.io",
})
) {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online",
promanager: "", //TODO:AIO Add in log rocket for promanager instances.
})
);
}
}
}, [bodyshop, client, currentUser.authorized]);
if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
} }
handleBeta(); checkUserSession();
}, [checkUserSession, setOnline]);
if (!online) //const b = Grid.useBreakpoint();
return ( // console.log("Breakpoints:", b);
<Result
status="warning" // Associate event listeners, memoize to prevent multiple listeners being added
title={t("general.labels.nointernet")} useEffect(() => {
subTitle={t("general.labels.nointernet_sub")} const offlineListener = (e) => {
extra={ setOnline(false);
<Button };
type="primary"
onClick={() => { const onlineListener = (e) => {
window.location.reload(); setOnline(true);
}} };
>
{t("general.actions.refresh")} if (!listenersAdded) {
</Button> console.log("Added events for offline and online");
} window.addEventListener("offline", offlineListener);
/> window.addEventListener("online", onlineListener);
setListenersAdded(true);
}
return () => {
window.removeEventListener("offline", offlineListener);
window.removeEventListener("online", onlineListener);
};
}, [setOnline, listenersAdded]);
useEffect(() => {
if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (
client.getTreatment("LogRocket_Tracking") === "on" ||
window.location.hostname ===
InstanceRenderMgr({
imex: "beta.imex.online",
rome: "beta.romeonline.io"
})
) {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({
imex: "gvfvfw/bodyshopapp",
rome: "rome-online/rome-online",
promanager: "" //TODO:AIO Add in log rocket for promanager instances.
})
); );
}
if (currentEula && !currentUser.eulaIsAccepted) {
return <Eula />;
} }
}, [bodyshop, client, currentUser.authorized]);
// Any route that is not assigned and matched will default to the Landing Page component if (currentUser.authorized === null) {
return <LoadingSpinner message={t("general.labels.loggingin")} />;
}
handleBeta();
if (!online)
return ( return (
<Result
<Suspense status="warning"
fallback={ title={t("general.labels.nointernet")}
<LoadingSpinner subTitle={t("general.labels.nointernet_sub")}
message={InstanceRenderMgr({ extra={
imex: t("titles.imexonline"), <Button
rome: t("titles.romeonline"), type="primary"
promanager: t("titles.promanager") onClick={() => {
})} window.location.reload();
/> }}
} >
> {t("general.actions.refresh")}
<ProductFruits </Button>
workspaceCode={InstanceRenderMgr({imex:null, rome: null, promanager:"aoJoEifvezYI0Z0P"})} }
debug />
language="en" user={{
email: currentUser.email,
username: currentUser.email,
}} />
<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>
); );
if (currentEula && !currentUser.eulaIsAccepted) {
return <Eula />;
}
// Any route that is not assigned and matched will default to the Landing Page component
return (
<Suspense
fallback={
<LoadingSpinner
message={InstanceRenderMgr({
imex: t("titles.imexonline"),
rome: t("titles.romeonline"),
promanager: t("titles.promanager")
})}
/>
}
>
<ProductFruits
workspaceCode={InstanceRenderMgr({
imex: null,
rome: null,
promanager: "aoJoEifvezYI0Z0P"
})}
debug
language="en"
user={{
email: currentUser.email,
username: currentUser.email
}}
/>
<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

@@ -154,7 +154,6 @@
font-style: italic; font-style: italic;
} }
.ant-table-tbody > tr.ant-table-row:nth-child(2n) > td { .ant-table-tbody > tr.ant-table-row:nth-child(2n) > td {
background-color: #f4f4f4; background-color: #f4f4f4;
} }

View File

@@ -1,8 +1,8 @@
import {defaultsDeep} from "lodash"; import { defaultsDeep } from "lodash";
import {theme} from "antd"; import { theme } from "antd";
import InstanceRenderMgr from '../utils/instanceRenderMgr' import InstanceRenderMgr from "../utils/instanceRenderMgr";
const {defaultAlgorithm, darkAlgorithm} = theme; const { defaultAlgorithm, darkAlgorithm } = theme;
let isDarkMode = false; let isDarkMode = false;
@@ -13,28 +13,28 @@ let isDarkMode = false;
const defaultTheme = { const defaultTheme = {
components: { components: {
Table: { Table: {
rowHoverBg: '#e7f3ff', rowHoverBg: "#e7f3ff",
rowSelectedBg: '#e6f7ff', rowSelectedBg: "#e6f7ff",
headerSortHoverBg: 'transparent', headerSortHoverBg: "transparent"
}, },
Menu: { Menu: {
darkItemHoverBg: '#1890ff', darkItemHoverBg: "#1890ff",
itemHoverBg: '#1890ff', itemHoverBg: "#1890ff",
horizontalItemHoverBg: '#1890ff', horizontalItemHoverBg: "#1890ff"
}, }
}, },
token: { token: {
colorPrimary: InstanceRenderMgr({ colorPrimary: InstanceRenderMgr({
imex: '#1890ff', imex: "#1890ff",
rome: '#326ade', rome: "#326ade",
promanager:"#1d69a6" promanager: "#1d69a6"
}), }),
colorInfo: InstanceRenderMgr({ colorInfo: InstanceRenderMgr({
imex: '#1890ff', imex: "#1890ff",
rome: '#326ade', rome: "#326ade",
promanager:"#1d69a6" promanager: "#1d69a6"
}), })
}, }
}; };
/** /**
@@ -42,16 +42,16 @@ const defaultTheme = {
* @type {{components: {Menu: {itemHoverBg: string, darkItemHoverBg: string, horizontalItemHoverBg: string}}, token: {colorPrimary: string}}} * @type {{components: {Menu: {itemHoverBg: string, darkItemHoverBg: string, horizontalItemHoverBg: string}}, token: {colorPrimary: string}}}
*/ */
const devTheme = { const devTheme = {
components: { components: {
Menu: { Menu: {
darkItemHoverBg: '#a51d1d', darkItemHoverBg: "#a51d1d",
itemHoverBg: '#a51d1d', itemHoverBg: "#a51d1d",
horizontalItemHoverBg: '#a51d1d', horizontalItemHoverBg: "#a51d1d"
}
},
token: {
colorPrimary: '#a51d1d'
} }
},
token: {
colorPrimary: "#a51d1d"
}
}; };
/** /**
@@ -60,11 +60,10 @@ const devTheme = {
*/ */
const prodTheme = {}; const prodTheme = {};
const currentTheme = import.meta.env.DEV ? devTheme const currentTheme = import.meta.env.DEV ? devTheme : prodTheme;
: prodTheme;
const finaltheme = { const finaltheme = {
algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm, algorithm: isDarkMode ? darkAlgorithm : defaultAlgorithm,
...defaultsDeep(currentTheme, defaultTheme) ...defaultsDeep(currentTheme, defaultTheme)
} };
export default finaltheme; export default finaltheme;

View File

@@ -131,4 +131,4 @@
"author": "iconkitchen", "author": "iconkitchen",
"version": 1 "version": 1
} }
} }

View File

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

View File

@@ -1,31 +1,30 @@
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,15 +1,16 @@
import {Card, Checkbox, Input, Space, Table} from "antd";import queryString from "query-string"; import { Card, Checkbox, Input, Space, Table } from "antd";
import React, {useState} from "react"; import queryString from "query-string";
import {useTranslation} from "react-i18next"; import React, { useState } from "react";
import {connect } from "react-redux"; import { useTranslation } from "react-i18next";
import {Link} from "react-router-dom"; import { connect } from "react-redux";
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} from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
import { pageLimit } from "../../utils/config"; import { pageLimit } from "../../utils/config";
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 PayableExportAll from "../payable-export-all-button/payable-export-all-button.component"; import PayableExportAll from "../payable-export-all-button/payable-export-all-button.component";
import PayableExportButton from "../payable-export-button/payable-export-button.component"; import PayableExportButton from "../payable-export-button/payable-export-button.component";
@@ -17,216 +18,179 @@ import BillMarkSelectedExported from "../payable-mark-selected-exported/payable-
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component"; import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.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, mapDispatchToProps)(AccountingPayablesTableComponent);
mapStateToProps,
mapDispatchToProps
)(AccountingPayablesTableComponent);
export function AccountingPayablesTableComponent({ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, refetch }) {
bodyshop, const { t } = useTranslation();
loading, const [selectedBills, setSelectedBills] = useState([]);
bills, const [transInProgress, setTransInProgress] = useState(false);
refetch, const [state, setState] = useState({
}) { sortedInfo: {},
const {t} = useTranslation(); search: ""
const [selectedBills, setSelectedBills] = useState([]); });
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
sortedInfo: {},
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",
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 {record.vendor.name}
loading={loading} </Link>
dataSource={dataSource} )
pagination={{position: "top", pageSize: pageLimit}} },
columns={columns} {
rowKey="id" title: t("bills.fields.invoice_number"),
onChange={handleTableChange} dataIndex: "invoice_number",
rowSelection={{ key: "invoice_number",
onSelectAll: (selected, selectedRows) => sorter: (a, b) => alphaSort(a.invoice_number, b.invoice_number),
setSelectedBills(selectedRows.map((i) => i.id)), sortOrder: state.sortedInfo.columnKey === "invoice_number" && state.sortedInfo.order,
onSelect: (record, selected, selectedRows, nativeEvent) => { render: (text, record) => (
setSelectedBills(selectedRows.map((i) => i.id)); <Link
}, to={{
getCheckboxProps: (record) => ({ pathname: `/manage/bills`,
disabled: record.exported, search: queryString.stringify({
}), billid: record.id,
selectedRowKeys: selectedBills, vendorid: record.vendor.id
type: "checkbox", })
}} }}
/> >
</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",
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,242 +1,198 @@
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 {pageLimit } from "../../utils/config"; import { pageLimit } from "../../utils/config";
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, { import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
OwnerNameDisplayFunction,
} 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";
import PaymentMarkSelectedExported from "../payment-mark-selected-exported/payment-mark-selected-exported.component"; import PaymentMarkSelectedExported from "../payment-mark-selected-exported/payment-mark-selected-exported.component";
import PaymentsExportAllButton from "../payments-export-all-button/payments-export-all-button.component"; import PaymentsExportAllButton from "../payments-export-all-button/payments-export-all-button.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component"; import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.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, mapDispatchToProps)(AccountingPayablesTableComponent);
mapStateToProps,
mapDispatchToProps
)(AccountingPayablesTableComponent);
export function AccountingPayablesTableComponent({ export function AccountingPayablesTableComponent({ bodyshop, loading, payments, refetch }) {
bodyshop, const { t } = useTranslation();
loading, const [selectedPayments, setSelectedPayments] = useState([]);
payments, const [transInProgress, setTransInProgress] = useState(false);
refetch, const [state, setState] = useState({
}) { sortedInfo: {},
const {t} = useTranslation(); search: ""
const [selectedPayments, setSelectedPayments] = useState([]); });
const [transInProgress, setTransInProgress] = useState(false);
const [state, setState] = useState({
sortedInfo: {},
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) => <Link to={"/manage/jobs/" + record.job.id}>{record.job.ro_number}</Link>
render: (text, record) => ( },
<Link to={"/manage/jobs/" + record.job.id}>{record.job.ro_number}</Link> {
), title: t("payments.fields.date"),
}, dataIndex: "date",
{ key: "date",
title: t("payments.fields.date"), sorter: (a, b) => dateSort(a.date, b.date),
dataIndex: "date", sortOrder: state.sortedInfo.columnKey === "date" && state.sortedInfo.order,
key: "date", render: (text, record) => <DateFormatter>{record.date}</DateFormatter>
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("jobs.fields.owner"), title: t("jobs.fields.owner"),
dataIndex: "owner", dataIndex: "owner",
key: "owner", key: "owner",
ellipsis: true, ellipsis: true,
sorter: (a, b) => sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a.job), OwnerNameDisplayFunction(b.job)),
alphaSort( sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
OwnerNameDisplayFunction(a.job), render: (text, record) => {
OwnerNameDisplayFunction(b.job) return record.job.owner ? (
), <Link to={"/manage/owners/" + record.job.owner.id}>
sortOrder: <OwnerNameDisplay ownerObject={record.job} />
state.sortedInfo.columnKey === "owner" && state.sortedInfo.order, </Link>
render: (text, record) => { ) : (
return record.job.owner ? ( <span>
<Link to={"/manage/owners/" + record.job.owner.id}> <OwnerNameDisplay ownerObject={record.job} />
<OwnerNameDisplay ownerObject={record.job}/>
</Link>
) : (
<span>
<OwnerNameDisplay ownerObject={record.job}/>
</span> </span>
); );
}, }
}, },
{ {
title: t("payments.fields.amount"), title: t("payments.fields.amount"),
dataIndex: "amount", dataIndex: "amount",
key: "amount", key: "amount",
sorter: (a, b) => a.amount - b.amount, sorter: (a, b) => a.amount - b.amount,
sortOrder: sortOrder: state.sortedInfo.columnKey === "amount" && state.sortedInfo.order,
state.sortedInfo.columnKey === "amount" && state.sortedInfo.order,render: (text, record) => ( render: (text, record) => <CurrencyFormatter>{record.amount}</CurrencyFormatter>
<CurrencyFormatter>{record.amount}</CurrencyFormatter> },
), {
}, title: t("payments.fields.memo"),
{ dataIndex: "memo",
title: t("payments.fields.memo"), key: "memo"
dataIndex: "memo", },
key: "memo", {
}, title: t("payments.fields.transactionid"),
{ dataIndex: "transactionid",
title: t("payments.fields.transactionid"), key: "transactionid"
dataIndex: "transactionid", },
key: "transactionid", {
}, title: t("payments.fields.created_at"),
{ dataIndex: "created_at",
title: t("payments.fields.created_at"), key: "created_at",
dataIndex: "created_at", sorter: (a, b) => dateSort(a.created_at, b.created_at),
key: "created_at",sorter: (a, b) => dateSort(a.created_at, b.created_at), sortOrder: state.sortedInfo.columnKey === "created_at" && state.sortedInfo.order,
sortOrder: render: (text, record) => <DateTimeFormatter>{record.created_at}</DateTimeFormatter>
state.sortedInfo.columnKey === "created_at" && state.sortedInfo.order, },
render: (text, record) => ( //{
<DateTimeFormatter>{record.created_at}</DateTimeFormatter> // title: t("payments.fields.exportedat"),
), // dataIndex: "exportedat",
}, // key: "exportedat",
//{ // render: (text, record) => (
// title: t("payments.fields.exportedat"), // <DateTimeFormatter>{record.exportedat}</DateTimeFormatter>
// dataIndex: "exportedat", // ),
// key: "exportedat", //},
// render: (text, record) => ( {
// <DateTimeFormatter>{record.exportedat}</DateTimeFormatter> title: t("exportlogs.labels.attempts"),
// ), dataIndex: "attempts",
//}, key: "attempts",
{
title: t("exportlogs.labels.attempts"),
dataIndex: "attempts",
key: "attempts",
render: (text, record) => ( render: (text, record) => <ExportLogsCountDisplay logs={record.exportlogs} />
<ExportLogsCountDisplay logs={record.exportlogs}/> },
), {
}, title: t("general.labels.actions"),
{ dataIndex: "actions",
title: t("general.labels.actions"), key: "actions",
dataIndex: "actions",
key: "actions",
render: (text, record) => (
<PaymentExportButton
paymentId={record.id}
disabled={transInProgress || !!record.exportedat}
loadingCallback={setTransInProgress}
setSelectedPayments={setSelectedPayments}
refetch={refetch}
/>
)
}
];
render: (text, record) => ( const handleSearch = (e) => {
<PaymentExportButton setState({ ...state, search: e.target.value });
paymentId={record.id} logImEXEvent("account_payments_table_search");
disabled={transInProgress || !!record.exportedat} };
loadingCallback={setTransInProgress}
setSelectedPayments={setSelectedPayments}
refetch={refetch}
/>
),
},
];
const handleSearch = (e) => { const dataSource = state.search
setState({...state, search: e.target.value}); ? payments.filter(
logImEXEvent("account_payments_table_search"); (v) =>
}; (v.paymentnum || "").toLowerCase().includes(state.search.toLowerCase()) ||
((v.job && v.job.ro_number) || "").toLowerCase().includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_fn) || "").toLowerCase().includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_ln) || "").toLowerCase().includes(state.search.toLowerCase()) ||
((v.job && v.job.ownr_co_nm) || "").toLowerCase().includes(state.search.toLowerCase())
)
: payments;
const dataSource = state.search return (
? payments.filter( <Card
(v) => extra={
(v.paymentnum || "") <Space wrap>
.toLowerCase() <PaymentMarkSelectedExported
.includes(state.search.toLowerCase()) || paymentIds={selectedPayments}
((v.job && v.job.ro_number) || "") disabled={transInProgress || selectedPayments.length === 0}
.toLowerCase() loadingCallback={setTransInProgress}
.includes(state.search.toLowerCase()) || completedCallback={setSelectedPayments}
((v.job && v.job.ownr_fn) || "") refetch={refetch}
.toLowerCase() />
.includes(state.search.toLowerCase()) || <PaymentsExportAllButton
((v.job && v.job.ownr_ln) || "") paymentIds={selectedPayments}
.toLowerCase() disabled={transInProgress || selectedPayments.length === 0}
.includes(state.search.toLowerCase()) || loadingCallback={setTransInProgress}
((v.job && v.job.ownr_co_nm) || "") completedCallback={setSelectedPayments}
.toLowerCase() refetch={refetch}
.includes(state.search.toLowerCase()) />
) {bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
: payments; <Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
</Space>
return ( }
<Card >
extra={ <Table
<Space wrap> loading={loading}
<PaymentMarkSelectedExported dataSource={dataSource}
paymentIds={selectedPayments} pagination={{ position: "top", pageSize: pageLimit }}
disabled={transInProgress || selectedPayments.length === 0} columns={columns}
loadingCallback={setTransInProgress} rowKey="id"
completedCallback={setSelectedPayments} onChange={handleTableChange}
refetch={refetch} rowSelection={{
/> onSelectAll: (selected, selectedRows) => setSelectedPayments(selectedRows.map((i) => i.id)),
<PaymentsExportAllButton onSelect: (record, selected, selectedRows, nativeEvent) => {
paymentIds={selectedPayments} setSelectedPayments(selectedRows.map((i) => i.id));
disabled={transInProgress || selectedPayments.length === 0} },
loadingCallback={setTransInProgress} getCheckboxProps: (record) => ({
completedCallback={setSelectedPayments} disabled: record.exported
refetch={refetch} }),
/> selectedRowKeys: selectedPayments,
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && ( type: "checkbox"
<QboAuthorizeComponent/> }}
)} />
<Input </Card>
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) =>
setSelectedPayments(selectedRows.map((i) => i.id)),
onSelect: (record, selected, selectedRows, nativeEvent) => {
setSelectedPayments(selectedRows.map((i) => i.id));
},
getCheckboxProps: (record) => ({
disabled: record.exported,
}),
selectedRowKeys: selectedPayments,
type: "checkbox",
}}
/>
</Card>
);
} }

View File

@@ -1,258 +1,212 @@
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, statusSort } from "../../utils/sorters"; import { alphaSort, dateSort, statusSort } 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 {DateFormatter} from "../../utils/DateFormatter"; import { DateFormatter } from "../../utils/DateFormatter";
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, { import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
OwnerNameDisplayFunction,
} from "../owner-name-display/owner-name-display.component";
import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.component"; import QboAuthorizeComponent from "../qbo-authorize/qbo-authorize.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, mapDispatchToProps)(AccountingReceivablesTableComponent);
mapStateToProps,
mapDispatchToProps
)(AccountingReceivablesTableComponent);
export function AccountingReceivablesTableComponent({ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, refetch }) {
bodyshop, const { t } = useTranslation();
loading, const [selectedJobs, setSelectedJobs] = useState([]);
jobs, const [transInProgress, setTransInProgress] = useState(false);
refetch,
}) {
const {t} = useTranslation();
const [selectedJobs, setSelectedJobs] = useState([]);
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) => <Link to={"/manage/jobs/" + record.id}>{record.ro_number}</Link>
render: (text, record) => ( },
<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) => statusSort(a, b, bodyshop.md_ro_statuses.statuses), sorter: (a, b) => statusSort(a, b, bodyshop.md_ro_statuses.statuses),
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: state.sortedInfo.columnKey === "date_invoiced" && state.sortedInfo.order,
sortOrder: render: (text, record) => <DateFormatter>{record.date_invoiced}</DateFormatter>
state.sortedInfo.columnKey === "date_invoiced" && },
state.sortedInfo.order, {
render: (text, record) => ( title: t("jobs.fields.owner"),
<DateFormatter>{record.date_invoiced}</DateFormatter> dataIndex: "owner",
), key: "owner",
}, sorter: (a, b) => alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)),
{ sortOrder: state.sortedInfo.columnKey === "owner" && state.sortedInfo.order,
title: t("jobs.fields.owner"), render: (text, record) => {
dataIndex: "owner", return record.owner ? (
key: "owner", <Link to={"/manage/owners/" + record.owner.id}>
sorter: (a, b) => <OwnerNameDisplay ownerObject={record} />
alphaSort(OwnerNameDisplayFunction(a), OwnerNameDisplayFunction(b)), </Link>
sortOrder: ) : (
state.sortedInfo.columnKey === "owner" && state.sortedInfo.order, <span>
render: (text, record) => { <OwnerNameDisplay ownerObject={record} />
return record.owner ? (
<Link to={"/manage/owners/" + record.owner.id}>
<OwnerNameDisplay ownerObject={record}/>
</Link>
) : (
<span>
<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,
sorter: (a, b) => sorter: (a, b) =>
alphaSort( alphaSort(
`${a.v_model_yr || ""} ${a.v_make_desc || ""} ${ `${a.v_model_yr || ""} ${a.v_make_desc || ""} ${a.v_model_desc || ""}`,
a.v_model_desc || ""
}`,
`${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}` `${b.v_model_yr || ""} ${b.v_make_desc || ""} ${b.v_model_desc || ""}`
), ),
sortOrder: sortOrder: state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,
state.sortedInfo.columnKey === "vehicle" && state.sortedInfo.order,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 || ""} ${record.v_model_desc || ""}`}</span>
) : ( );
<span>{`${record.v_model_yr || ""} ${record.v_make_desc || ""} ${ }
record.v_model_desc || "" },
}`}</span> {
); title: t("jobs.fields.clm_no"),
}, dataIndex: "clm_no",
}, key: "clm_no",
{ ellipsis: true,
title: t("jobs.fields.clm_no"), sorter: (a, b) => alphaSort(a.clm_no, b.clm_no),
dataIndex: "clm_no", sortOrder: state.sortedInfo.columnKey === "clm_no" && state.sortedInfo.order
key: "clm_no", },
ellipsis: true, {
sorter: (a, b) => alphaSort(a.clm_no, b.clm_no), title: t("jobs.fields.clm_total"),
sortOrder: dataIndex: "clm_total",
state.sortedInfo.columnKey === "clm_no" && state.sortedInfo.order, key: "clm_total",
}, sorter: (a, b) => a.clm_total - b.clm_total,
{ sortOrder: state.sortedInfo.columnKey === "clm_total" && state.sortedInfo.order,
title: t("jobs.fields.clm_total"), render: (text, record) => {
dataIndex: "clm_total", return <CurrencyFormatter>{record.clm_total}</CurrencyFormatter>;
key: "clm_total", }
sorter: (a, b) => a.clm_total - b.clm_total, },
sortOrder: {
state.sortedInfo.columnKey === "clm_total" && state.sortedInfo.order, title: t("exportlogs.labels.attempts"),
render: (text, record) => { dataIndex: "attempts",
return <CurrencyFormatter>{record.clm_total}</CurrencyFormatter>; key: "attempts",
}, render: (text, record) => <ExportLogsCountDisplay logs={record.exportlogs} />
}, },
{ {
title: t("exportlogs.labels.attempts"), title: t("general.labels.actions"),
dataIndex: "attempts", dataIndex: "actions",
key: "attempts", key: "actions",
render: (text, record) => (
<ExportLogsCountDisplay logs={record.exportlogs}/>
),
},
{
title: t("general.labels.actions"),
dataIndex: "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().toLowerCase().includes(state.search.toLowerCase()) ||
.toString() (v.ownr_fn || "").toLowerCase().includes(state.search.toLowerCase()) ||
.toLowerCase() (v.ownr_ln || "").toLowerCase().includes(state.search.toLowerCase()) ||
.includes(state.search.toLowerCase()) || (v.ownr_co_nm || "").toLowerCase().includes(state.search.toLowerCase()) ||
(v.ownr_fn || "") (v.v_model_desc || "").toLowerCase().includes(state.search.toLowerCase()) ||
.toLowerCase() (v.v_make_desc || "").toLowerCase().includes(state.search.toLowerCase()) ||
.includes(state.search.toLowerCase()) || (v.clm_no || "").toLowerCase().includes(state.search.toLowerCase())
(v.ownr_ln || "") )
.toLowerCase() : jobs;
.includes(state.search.toLowerCase()) ||
(v.ownr_co_nm || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.v_model_desc || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.v_make_desc || "")
.toLowerCase()
.includes(state.search.toLowerCase()) ||
(v.clm_no || "").toLowerCase().includes(state.search.toLowerCase())
)
: 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,67 @@
import {Button, InputNumber, Popover, Select} 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
showSearch id="employeeSelector"
style={{width: 200}} showSearch
placeholder='Select a person' style={{ width: 200 }}
optionFilterProp='children' placeholder="Select a person"
onChange={onChange} optionFilterProp="children"
filterOption={(input, option) => onChange={onChange}
option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 filterOption={(input, option) => 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" disabled={!assignment.employeeid} onClick={handleAssignment}>
type='primary' Assign
disabled={!assignment.employeeid} </Button>
onClick={handleAssignment}> <Button onClick={() => setVisibility(false)}>Close</Button>
Assign </div>
</Button> );
<Button onClick={() => setVisibility(false)}>Close</Button>
</div>
);
return ( return (
<Popover content={popContent} open={visibility}> <Popover content={popContent} open={visibility}>
<Button onClick={() => setVisibility(true)}> <Button onClick={() => setVisibility(true)}>{t("allocations.actions.assign")}</Button>
{t("allocations.actions.assign")} </Popover>
</Button> );
</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,43 @@
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, hours, refetch }) {
jobLineId, const visibilityState = useState(false);
hours, const { t } = useTranslation();
refetch, const [assignment, setAssignment] = useState({
}) { joblineid: jobLineId,
const visibilityState = useState(false); hours: parseFloat(hours),
const {t} = useTranslation(); employeeid: null
const [assignment, setAssignment] = useState({ });
joblineid: jobLineId, const [insertAllocation] = useMutation(INSERT_ALLOCATION);
hours: parseFloat(hours),
employeeid: null,
});
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,62 @@
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) => (
> <Select.Option value={emp.id} key={emp.id}>
{bodyshop.employees.map((emp) => ( {`${emp.first_name} ${emp.last_name}`}
<Select.Option value={emp.id} key={emp.id}> </Select.Option>
{`${emp.first_name} ${emp.last_name}`} ))}
</Select.Option> </Select>
))}
</Select>
<Button <Button type="primary" disabled={!assignment.employeeid} onClick={handleAssignment}>
type="primary" Assign
disabled={!assignment.employeeid} </Button>
onClick={handleAssignment} <Button onClick={() => setVisibility(false)}>Close</Button>
> </div>
Assign );
</Button>
<Button onClick={() => setVisibility(false)}>Close</Button>
</div>
);
return ( return (
<Popover content={popContent} open={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,44 @@
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, refetch }) {
jobLines, const visibilityState = useState(false);
refetch, const { t } = useTranslation();
}) { const [assignment, setAssignment] = useState({
const visibilityState = useState(false); employeeid: null
const {t} = useTranslation(); });
const [assignment, setAssignment] = useState({ const [insertAllocation] = useMutation(INSERT_ALLOCATION);
employeeid: null,
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); };
const handleAssignment = () => { return (
const allocs = jobLines.reduce((acc, value) => { <AllocationsBulkAssignment
acc.push({ disabled={jobLines.length > 0 ? false : true}
joblineid: value.id, handleAssignment={handleAssignment}
hours: parseFloat(value.mod_lb_hrs) || 0, assignment={assignment}
employeeid: assignment.employeeid, setAssignment={setAssignment}
}); 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,14 @@
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.hours || ""})`}
allocation.employee.last_name || ""
} (${allocation.hours || ""})`}
</span> </span>
<Icon <Icon style={{ color: "red", padding: "0px 4px" }} component={MdRemoveCircleOutline} onClick={handleClick} />
style={{color: "red", padding: "0px 4px"}} </div>
component={MdRemoveCircleOutline} );
onClick={handleClick}
/>
</div>
);
} }

View File

@@ -1,32 +1,27 @@
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 allocation={allocation} handleClick={handleClick} />;
<AllocationsLabelComponent
allocation={allocation}
handleClick={handleClick}
/>
);
} }

View File

@@ -1,85 +1,75 @@
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,
), sortOrder: state.sortedInfo.columnKey === "created" && state.sortedInfo.order
sorter: (a, b) => a.created - b.created, },
sortOrder: {
state.sortedInfo.columnKey === "created" && state.sortedInfo.order, title: t("audit.fields.operation"),
}, dataIndex: "operation",
{ key: "operation",
title: t("audit.fields.operation"), width: "10%",
dataIndex: "operation", sorter: (a, b) => alphaSort(a.operation, b.operation),
key: "operation", sortOrder: state.sortedInfo.columnKey === "operation" && state.sortedInfo.order
width: "10%", },
sorter: (a, b) => alphaSort(a.operation, b.operation), {
sortOrder: title: t("audit.fields.values"),
state.sortedInfo.columnKey === "operation" && state.sortedInfo.order, dataIndex: " old_val",
}, key: " old_val",
{ width: "10%",
title: t("audit.fields.values"), render: (text, record) => <AuditTrailValuesComponent oldV={record.old_val} newV={record.new_val} />
dataIndex: " old_val", },
key: " old_val", {
width: "10%", title: t("audit.fields.useremail"),
render: (text, record) => ( dataIndex: "useremail",
<AuditTrailValuesComponent key: "useremail",
oldV={record.old_val} width: "10%",
newV={record.new_val} sorter: (a, b) => alphaSort(a.useremail, b.useremail),
/> sortOrder: state.sortedInfo.columnKey === "useremail" && state.sortedInfo.order
), }
}, ];
{
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 = { 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,40 +1,34 @@
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} data={data ? data.audit_trail : []} />
loading={loading} </Card>
data={data ? data.audit_trail : []} <Card>
/> <EmailAuditTrailListComponent loading={loading} data={data ? data.audit_trail : []} />
</Card> </Card>
<Card> </Row>
<EmailAuditTrailListComponent )}
loading={loading} </div>
data={data ? data.audit_trail : []} );
/>
</Card>
</Row>
)}
</div>
);
} }

View File

@@ -1,64 +1,60 @@
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,
), sortOrder: state.sortedInfo.columnKey === "created" && state.sortedInfo.order
sorter: (a, b) => a.created - b.created, },
sortOrder:
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,30 +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}) { export default function AuditTrailValuesComponent({ oldV, newV }) {
if (!oldV && !newV) return <div></div>; 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(oldV).map((key, idx) => ( {Object.keys(newV).map((key, idx) => (
<List.Item key={idx}> <List.Item key={idx} value={key}>
{key}: {oldV[key]} <Icon component={FaArrowRight}/> {key}: {JSON.stringify(newV[key])}
{JSON.stringify(newV[key])} </List.Item>
</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,23 +1,15 @@
import {Popover, Tag} 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}) { export default function BarcodePopupComponent({ value, children }) {
const {t} = useTranslation(); const { t } = useTranslation();
return ( return (
<div> <div>
<Popover <Popover content={<Barcode value={value || ""} background="transparent" displayValue={false} />}>
content={ {children ? children : <Tag>{t("general.labels.barcode")}</Tag>}
<Barcode </Popover>
value={value || ""} </div>
background="transparent" );
displayValue={false}
/>
}
>
{children ? children : <Tag>{t("general.labels.barcode")}</Tag>}
</Popover>
</div>
);
} }

View File

@@ -1,136 +1,128 @@
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, returnLoading, returnData }) {
form, const { t } = useTranslation();
returnLoading,
returnData,
}) {
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]);
return ( if (returnLoading) return <Skeleton />;
<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 (
return null; <Form.List name="outstanding_returns">
} {(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>
if (returnLoading) return <Skeleton/>; <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>
return ( <td>
<Form.List name="outstanding_returns"> <Form.Item
{(fields, {add, remove, move}) => { span={2}
return ( //label={t("joblines.fields.mod_lb_hrs")}
<> key={`${index}cm_received`}
<Typography.Title level={4}> name={[field.name, "cm_received"]}
{t("bills.labels.creditsnotreceived")} valuePropName="checked"
</Typography.Title> >
<table className="bill-cm-returns-table"> <Checkbox />
<thead> </Form.Item>
<tr> </td>
<th>{t("parts_orders.fields.line_desc")}</th> </tr>
<th>{t("parts_orders.fields.part_type")}</th> ))}
<th>{t("parts_orders.fields.quantity")}</th> </tbody>
<th>{t("parts_orders.fields.act_price")}</th> </table>
<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.Item> </Form.List>
); );
}}
</Form.Item>
);
} }

View File

@@ -16,4 +16,4 @@
tr:hover { tr:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
} }

View File

@@ -1,97 +1,88 @@
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";
import {insertAuditTrail} from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
const mapStateToProps = createStructuredSelector({}); const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
insertAuditTrail: ({ jobid, operation, type }) => insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
dispatch(insertAuditTrail({ jobid, operation, type })),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(BillDeleteButton); export default connect(mapStateToProps, mapDispatchToProps)(BillDeleteButton);
export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) { export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
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") });
insertAuditTrail({ insertAuditTrail({
jobid: jobid, jobid: jobid,
operation: AuditTrailMapping.billdeleted(bill.invoice_number), operation: AuditTrailMapping.billdeleted(bill.invoice_number),
type: "billdeleted", type: "billdeleted"
}); });
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} onConfirm={handleDelete} title={t("bills.labels.deleteconfirm")}>
disabled={bill.exported} <Button
onConfirm={handleDelete} disabled={bill.exported}
title={t("bills.labels.deleteconfirm")} // onClick={handleDelete}
> loading={loading}
<Button >
disabled={bill.exported} <DeleteFilled />
// onClick={handleDelete} </Button>
loading={loading} </Popconfirm>
> </RbacWrapper>
<DeleteFilled/> );
</Button>
</Popconfirm>
</RbacWrapper>
);
} }

View File

@@ -1,17 +1,17 @@
import {useMutation, useQuery} from "@apollo/client"; import { useMutation, useQuery } from "@apollo/client";
import {Button, Divider, Form, Popconfirm, Space} from "antd"; import { Button, Divider, Form, Popconfirm, Space } from "antd";
import dayjs from "../../utils/day"; 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 {DELETE_BILL_LINE, INSERT_NEW_BILL_LINES, UPDATE_BILL_LINE} from "../../graphql/bill-lines.queries"; import { DELETE_BILL_LINE, INSERT_NEW_BILL_LINES, UPDATE_BILL_LINE } from "../../graphql/bill-lines.queries";
import {QUERY_BILL_BY_PK, UPDATE_BILL} from "../../graphql/bills.queries"; import { QUERY_BILL_BY_PK, UPDATE_BILL } from "../../graphql/bills.queries";
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 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";
@@ -22,227 +22,212 @@ 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"; 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, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
insertAuditTrail: ({jobid, operation, type}) =>
dispatch(insertAuditTrail({jobid, operation, type })),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditcontainer);
mapStateToProps,
mapDispatchToProps
)(BillDetailEditcontainer);
export function BillDetailEditcontainer({setPartsOrderContext, insertAuditTrail, bodyshop,}) { export function BillDetailEditcontainer({ setPartsOrderContext, insertAuditTrail, bodyshop }) {
const search = queryString.parse(useLocation().search); const search = queryString.parse(useLocation().search);
const {t} = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [open, setOpen] = 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"
}); });
// ... rest of the code remains the same // ... rest of the code remains the same
const handleSave = () => { const handleSave = () => {
//It's got a previously deducted bill line! //It's got a previously deducted bill line!
if ( if (
data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 || data.bills_by_pk.billlines.filter((b) => b.deductedfromlbr).length > 0 ||
form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length > form.getFieldValue("billlines").filter((b) => b.deductedfromlbr).length > 0
0 )
) setOpen(true);
setOpen(true); else {
else { form.submit();
form.submit(); }
} };
};
const handleFinish = async (values) => { const handleFinish = async (values) => {
setUpdateLoading(true); setUpdateLoading(true);
//let adjustmentsToInsert = {}; //let adjustmentsToInsert = {};
const {billlines, upload, ...bill} = values; const { billlines, upload, ...bill } = values;
const updates = []; const updates = [];
updates.push( updates.push(
update_bill({ update_bill({
variables: {billId: search.billid, bill: bill}, variables: { billId: search.billid, bill: bill }
}) })
);
billlines.forEach((l) => {
delete l.selected;
});
//Find bill lines that were deleted.
const deletedJobLines = [];
data.bills_by_pk.billlines.forEach((a) => {
const matchingRecord = billlines.find((b) => b.id === a.id);
if (!matchingRecord) {
deletedJobLines.push(a);
}
});
deletedJobLines.forEach((d) => {
updates.push(deleteBillLine({variables: {id: d.id}}));
});
billlines.forEach((billline) => {
const {deductedfromlbr, inventories, jobline, ...il} = billline;
delete il.__typename;
if (il.id) {
updates.push(
updateBillLine({
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),
type: "billupdated",
});
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 (
<>
{loading && <LoadingSkeleton/>}
{data && (
<>
<PageHeader
title={
data &&
`${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}`
}
extra={
<Space>
<BillDetailEditReturn data={data}/>
<BillPrintButton billid={search.billid}/>
<Popconfirm
open={open}
onConfirm={() => form.submit()}
onCancel={() => setOpen(false)}
okButtonProps={{loading: updateLoading}}
title={t("bills.labels.editadjwarning")}
>
<Button
htmlType="submit"
disabled={exported}
onClick={handleSave}
loading={updateLoading}
type="primary"
>
{t("general.actions.save")}
</Button>
</Popconfirm>
<BillReeportButtonComponent bill={data && data.bills_by_pk}/>
<BillMarkExportedButton bill={data && data.bills_by_pk}/>
</Space>
}
/>
<Form
form={form}
onFinish={handleFinish}
initialValues={transformData(data)}
layout="vertical"
>
<BillFormContainer form={form} billEdit disabled={exported}/>
<Divider orientation="left">{t("general.labels.media")}</Divider>
{bodyshop.uselocalmediaserver ? (
<JobsDocumentsLocalGallery
job={{id: data ? data.bills_by_pk.jobid : null}}
invoice_number={data ? data.bills_by_pk.invoice_number : null}
vendorid={data ? data.bills_by_pk.vendorid : null}
/>
) : (
<JobDocumentsGallery
jobId={data ? data.bills_by_pk.jobid : null}
billId={search.billid}
documentsList={data ? data.bills_by_pk.documents : []}
billsCallback={refetch}
/>
)}
</Form>
</>
)}
</>
); );
billlines.forEach((l) => {
delete l.selected;
});
//Find bill lines that were deleted.
const deletedJobLines = [];
data.bills_by_pk.billlines.forEach((a) => {
const matchingRecord = billlines.find((b) => b.id === a.id);
if (!matchingRecord) {
deletedJobLines.push(a);
}
});
deletedJobLines.forEach((d) => {
updates.push(deleteBillLine({ variables: { id: d.id } }));
});
billlines.forEach((billline) => {
const { deductedfromlbr, inventories, jobline, ...il } = billline;
delete il.__typename;
if (il.id) {
updates.push(
updateBillLine({
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),
type: "billupdated"
});
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 (
<>
{loading && <LoadingSkeleton />}
{data && (
<>
<PageHeader
title={data && `${data.bills_by_pk.invoice_number} - ${data.bills_by_pk.vendor.name}`}
extra={
<Space>
<BillDetailEditReturn data={data} />
<BillPrintButton billid={search.billid} />
<Popconfirm
open={open}
onConfirm={() => form.submit()}
onCancel={() => setOpen(false)}
okButtonProps={{ loading: updateLoading }}
title={t("bills.labels.editadjwarning")}
>
<Button
htmlType="submit"
disabled={exported}
onClick={handleSave}
loading={updateLoading}
type="primary"
>
{t("general.actions.save")}
</Button>
</Popconfirm>
<BillReeportButtonComponent bill={data && data.bills_by_pk} />
<BillMarkExportedButton bill={data && data.bills_by_pk} />
</Space>
}
/>
<Form form={form} onFinish={handleFinish} initialValues={transformData(data)} layout="vertical">
<BillFormContainer form={form} billEdit disabled={exported} />
<Divider orientation="left">{t("general.labels.media")}</Divider>
{bodyshop.uselocalmediaserver ? (
<JobsDocumentsLocalGallery
job={{ id: data ? data.bills_by_pk.jobid : null }}
invoice_number={data ? data.bills_by_pk.invoice_number : null}
vendorid={data ? data.bills_by_pk.vendorid : null}
/>
) : (
<JobDocumentsGallery
jobId={data ? data.bills_by_pk.jobid : null}
billId={search.billid}
documentsList={data ? data.bills_by_pk.documents : []}
billsCallback={refetch}
/>
)}
</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 ? dayjs(data.bills_by_pk.date) : null
date: data.bills_by_pk ? dayjs(data.bills_by_pk.date) : null, }
} : {};
: {};
}; };

View File

@@ -1,189 +1,168 @@
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 {useLocation, useNavigate} 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, type }) => dispatch(insertAuditTrail({ jobid, operation, type }))
insertAuditTrail: ({jobid, operation, type}) =>
dispatch(insertAuditTrail({jobid, operation, type })),
}); });
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(BillDetailEditReturn);
mapStateToProps,
mapDispatchToProps
)(BillDetailEditReturn);
export function BillDetailEditReturn({ export function BillDetailEditReturn({ setPartsOrderContext, insertAuditTrail, bodyshop, data, disabled }) {
setPartsOrderContext, const search = queryString.parse(useLocation().search);
insertAuditTrail, const history = useNavigate();
bodyshop, const { t } = useTranslation();
data, const [form] = Form.useForm();
disabled, const [open, setOpen] = useState(false);
}) {
const search = queryString.parse(useLocation().search);
const history = useNavigate();
const {t} = useTranslation();
const [form] = Form.useForm();
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({search: queryString.stringify(search)}); history({ search: queryString.stringify(search) });
setOpen(false); setOpen(false);
}; };
useEffect(() => { useEffect(() => {
if (open === false) form.resetFields(); if (open === false) form.resetFields();
}, [open, form]); }, [open, form]);
return ( return (
<> <>
<Modal <Modal
open={open} open={open}
onCancel={() => setOpen(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} onFinish={handleFinish} form={form}>
initialValues={data && data.bills_by_pk} <Form.List name={["billlines"]}>
onFinish={handleFinish} {(fields, { add, remove, move }) => {
form={form} return (
> <table style={{ tableLayout: "auto", width: "100%" }}>
<Form.List name={["billlines"]}> <thead>
{(fields, {add, remove, move}) => { <tr>
return ( <td>
<table style={{tableLayout: "auto", width: "100%"}}> <Checkbox
<thead> onChange={(e) => {
<tr> form.setFieldsValue({
<td> billlines: form.getFieldsValue().billlines.map((b) => ({
<Checkbox ...b,
onChange={(e) => { selected: e.target.checked
form.setFieldsValue({ }))
billlines: form });
.getFieldsValue() }}
.billlines.map((b) => ({ />
...b, </td>
selected: e.target.checked, <td>{t("billlines.fields.line_desc")}</td>
})), <td>{t("billlines.fields.quantity")}</td>
}); <td>{t("billlines.fields.actual_price")}</td>
}} <td>{t("billlines.fields.actual_cost")}</td>
/> </tr>
</td> </thead>
<td>{t("billlines.fields.line_desc")}</td> <tbody>
<td>{t("billlines.fields.quantity")}</td> {fields.map((field, index) => (
<td>{t("billlines.fields.actual_price")}</td> <tr key={field.key}>
<td>{t("billlines.fields.actual_cost")}</td> <td>
</tr> <Form.Item
</thead> // label={t("joblines.fields.selected")}
<tbody> key={`${index}selected`}
{fields.map((field, index) => ( name={[field.name, "selected"]}
<tr key={field.key}> valuePropName="checked"
<td> >
<Form.Item <Checkbox />
// label={t("joblines.fields.selected")} </Form.Item>
key={`${index}selected`} </td>
name={[field.name, "selected"]} <td>
valuePropName="checked" <Form.Item
> // label={t("joblines.fields.line_desc")}
<Checkbox/> key={`${index}line_desc`}
</Form.Item> name={[field.name, "line_desc"]}
</td> >
<td> <ReadOnlyFormItemComponent />
<Form.Item </Form.Item>
// label={t("joblines.fields.line_desc")} </td>
key={`${index}line_desc`} <td>
name={[field.name, "line_desc"]} <Form.Item
> // label={t("joblines.fields.quantity")}
<ReadOnlyFormItemComponent/> key={`${index}quantity`}
</Form.Item> name={[field.name, "quantity"]}
</td> >
<td> <ReadOnlyFormItemComponent />
<Form.Item </Form.Item>
// label={t("joblines.fields.quantity")} </td>
key={`${index}quantity`} <td>
name={[field.name, "quantity"]} <Form.Item
> // label={t("joblines.fields.actual_price")}
<ReadOnlyFormItemComponent/> key={`${index}actual_price`}
</Form.Item> name={[field.name, "actual_price"]}
</td> >
<td> <ReadOnlyFormItemComponent type="currency" />
<Form.Item </Form.Item>
// label={t("joblines.fields.actual_price")} </td>
key={`${index}actual_price`} <td>
name={[field.name, "actual_price"]} <Form.Item
> // label={t("joblines.fields.actual_cost")}
<ReadOnlyFormItemComponent type="currency"/> key={`${index}actual_cost`}
</Form.Item> name={[field.name, "actual_cost"]}
</td> >
<td> <ReadOnlyFormItemComponent type="currency" />
<Form.Item </Form.Item>
// label={t("joblines.fields.actual_cost")} </td>
key={`${index}actual_cost`} </tr>
name={[field.name, "actual_cost"]} ))}
> </tbody>
<ReadOnlyFormItemComponent type="currency"/> </table>
</Form.Item> );
</td> }}
</tr> </Form.List>
))} </Form>
</tbody> </Modal>
</table> <Button
); disabled={data.bills_by_pk.is_credit_memo || data.bills_by_pk.isinhouse || disabled}
}} onClick={() => {
</Form.List> setOpen(true);
</Form> }}
</Modal> >
<Button {t("bills.actions.return")}
disabled={ </Button>
data.bills_by_pk.is_credit_memo || </>
data.bills_by_pk.isinhouse || );
disabled
}
onClick={() => {
setOpen(true);
}}
>
{t("bills.actions.return")}
</Button>
</>
);
} }

View File

@@ -1,40 +1,38 @@
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 {useLocation, useNavigate} 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 = useNavigate(); 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]] : "100%";
? bpoints[selectedBreakpoint[0]]
: "100%";
return ( return (
<Drawer <Drawer
width={drawerPercentage} width={drawerPercentage}
onClose={() => { onClose={() => {
delete search.billid; delete search.billid;
history({search: queryString.stringify(search)}); history({ search: queryString.stringify(search) });
}} }}
destroyOnClose destroyOnClose
open={search.billid} open={search.billid}
> >
<BillDetailEditComponent/> <BillDetailEditComponent />
</Drawer> </Drawer>
); );
} }

View File

@@ -1,471 +1,426 @@
import {useApolloClient, useMutation} from "@apollo/client"; import { useApolloClient, useMutation } from "@apollo/client";
import {useSplitTreatments} from "@splitsoftware/splitio-react"; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import {Button, Checkbox, Form, Modal, notification, Space} 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 {QUERY_JOB_LBR_ADJUSTMENTS, UPDATE_JOB,} from "../../graphql/jobs.queries"; import { QUERY_JOB_LBR_ADJUSTMENTS, UPDATE_JOB } from "../../graphql/jobs.queries";
import {MUTATION_MARK_RETURN_RECEIVED} from "../../graphql/parts-orders.queries"; import { MUTATION_MARK_RETURN_RECEIVED } from "../../graphql/parts-orders.queries";
import {insertAuditTrail} from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import {toggleModalVisible} from "../../redux/modals/modals.actions"; import { toggleModalVisible } from "../../redux/modals/modals.actions";
import {selectBillEnterModal} from "../../redux/modals/modals.selectors"; import { selectBillEnterModal } from "../../redux/modals/modals.selectors";
import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.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, type}) => insertAuditTrail: ({ jobid, billid, operation, type }) =>
dispatch(insertAuditTrail({jobid, billid, operation, type })), dispatch(insertAuditTrail({ jobid, billid, operation, type }))
}); });
const Templates = TemplateList("job_special"); const Templates = TemplateList("job_special");
function BillEnterModalContainer({ function BillEnterModalContainer({ billEnterModal, toggleModalVisible, bodyshop, currentUser, insertAuditTrail }) {
billEnterModal, const [form] = Form.useForm();
toggleModalVisible, const { t } = useTranslation();
bodyshop, const [enterAgain, setEnterAgain] = useState(false);
currentUser, const [insertBill] = useMutation(INSERT_NEW_BILL);
insertAuditTrail, const [updateJobLines] = useMutation(UPDATE_JOB_LINE);
}) { const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
const [form] = Form.useForm(); const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
const {t} = useTranslation(); const [loading, setLoading] = useState(false);
const [enterAgain, setEnterAgain] = useState(false); const client = useApolloClient();
const [insertBill] = useMutation(INSERT_NEW_BILL); const [generateLabel, setGenerateLabel] = useLocalStorage("enter_bill_generate_label", false);
const [updateJobLines] = useMutation(UPDATE_JOB_LINE);
const [updatePartsOrderLines] = useMutation(MUTATION_MARK_RETURN_RECEIVED);
const [updateInventoryLines] = useMutation(UPDATE_INVENTORY_LINES);
const [loading, setLoading] = useState(false);
const client = useApolloClient();
const [generateLabel, setGenerateLabel] = useLocalStorage(
"enter_bill_generate_label",
false
);
const {treatments: {Enhanced_Payroll}} = useSplitTreatments({ const {
attributes: {}, treatments: { Enhanced_Payroll }
names: ["Enhanced_Payroll"], } = useSplitTreatments({
splitKey: bodyshop.imexshopid, attributes: {},
names: ["Enhanced_Payroll"],
splitKey: 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, federal_tax_exempt, ...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"],
awaitRefetchQueries: true
}); });
const formValues = useMemo(() => { await Promise.all(
return { payrollAdjustmentsToInsert.map((li) => {
...billEnterModal.context.bill, return updateJobLines({
//Added as a part of IO-2436 for capturing parts price changes. variables: {
billlines: billEnterModal.context?.bill?.billlines?.map((line) => ({ lineId: li.id,
...line, line: {
original_actual_price: line.actual_price, convertedtolbr: li.convertedtolbr,
})), convertedtolbr_data: li.convertedtolbr_data
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,
federal_tax_exempt,
...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"],
awaitRefetchQueries: true
}); });
})
);
await Promise.all( const adjKeys = Object.keys(adjustmentsToInsert);
payrollAdjustmentsToInsert.map((li) => { if (adjKeys.length > 0) {
return updateJobLines({ //Query the adjustments, merge, and update them.
variables: { const existingAdjustments = await client.query({
lineId: li.id, query: QUERY_JOB_LBR_ADJUSTMENTS,
line: { variables: {
convertedtolbr: li.convertedtolbr, id: values.jobid
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),
}),
type: "jobmodifylbradj",});
});
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 = const newAdjustments = _.cloneDeep(existingAdjustments.data.jobs_by_pk.lbr_adjustments);
outstanding_returns &&
outstanding_returns.filter((o) => o.cm_received === true);
if (markPolReceived && markPolReceived.length > 0) { adjKeys.forEach((key) => {
const r2 = await updatePartsOrderLines({ newAdjustments[key] = (newAdjustments[key] || 0) + adjustmentsToInsert[key];
variables: {partsLineIds: markPolReceived.map((p) => p.id)},
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID" ],
});
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,
billid: billId, operation: AuditTrailMapping.jobmodifylbradj({
operation: AuditTrailMapping.billposted( mod_lbr_ty: key,
r1.data.insert_bills.returning[0].invoice_number hours: adjustmentsToInsert[key].toFixed(1)
), }),
type: "billposted", type: "jobmodifylbradj"
});
});
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) },
refetchQueries: ["QUERY_PARTS_BILLS_BY_JOBID"]
});
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 (enterAgain) { if (generateLabel) {
form.resetFields(); GenerateDocument(
form.setFieldsValue({ {
...formValues, name: Templates.parts_invoice_label_single.key,
vendorid:values.vendorid, variables: {
billlines: [], id: billId
}); }
// form.resetFields(); },
} else { {},
toggleModalVisible(); "p"
} );
setEnterAgain(false); }
};
const handleCancel = () => { if (billEnterModal.actions.refetch) billEnterModal.actions.refetch();
const r = window.confirm(t("general.labels.cancel"));
if (r === true) {
toggleModalVisible();
}
};
useEffect(() => { insertAuditTrail({
if (enterAgain) form.submit(); jobid: values.jobid,
}, [enterAgain, form]); billid: billId,
operation: AuditTrailMapping.billposted(r1.data.insert_bills.returning[0].invoice_number),
type: "billposted"
});
useEffect(() => { if (enterAgain) {
if (billEnterModal.open) { form.resetFields();
form.setFieldsValue(formValues); form.setFieldsValue({
} else { ...formValues,
form.resetFields(); vendorid: values.vendorid,
} billlines: []
}, [billEnterModal.open, form, formValues]); });
// form.resetFields();
} else {
toggleModalVisible();
}
setEnterAgain(false);
};
return ( const handleCancel = () => {
<Modal const r = window.confirm(t("general.labels.cancel"));
title={t("bills.labels.new")} if (r === true) {
width={"98%"} toggleModalVisible();
open={billEnterModal.open} }
okText={t("general.actions.save")} };
keyboard="false"
onOk={() => form.submit()} useEffect(() => {
onCancel={handleCancel} if (enterAgain) form.submit();
afterClose={() => { }, [enterAgain, form]);
form.resetFields();
setLoading(false); useEffect(() => {
}} if (billEnterModal.open) {
footer={ form.setFieldsValue(formValues);
<Space> } else {
<Checkbox form.resetFields();
checked={generateLabel} }
onChange={(e) => setGenerateLabel(e.target.checked)} }, [billEnterModal.open, form, formValues]);
>
{t("bills.labels.generatepartslabel")} return (
</Checkbox> <Modal
<Button onClick={handleCancel}>{t("general.actions.cancel")}</Button> title={t("bills.labels.new")}
<Button loading={loading} onClick={() => form.submit()}> width={"98%"}
{t("general.actions.save")} open={billEnterModal.open}
</Button> okText={t("general.actions.save")}
{billEnterModal.context && billEnterModal.context.id ? null : ( keyboard="false"
<Button onOk={() => form.submit()}
type="primary" onCancel={handleCancel}
loading={loading} afterClose={() => {
onClick={() => { form.resetFields();
setEnterAgain(true); setLoading(false);
}} }}
> footer={
{t("general.actions.saveandnew")} <Space>
</Button> <Checkbox checked={generateLabel} onChange={(e) => setGenerateLabel(e.target.checked)}>
)} {t("bills.labels.generatepartslabel")}
</Space> </Checkbox>
} <Button onClick={handleCancel}>{t("general.actions.cancel")}</Button>
destroyOnClose <Button loading={loading} onClick={() => form.submit()}>
> {t("general.actions.save")}
<Form </Button>
onFinish={handleFinish} {billEnterModal.context && billEnterModal.context.id ? null : (
autoComplete={"off"} <Button
layout="vertical" type="primary"
form={form} loading={loading}
onFinishFailed={() => { onClick={() => {
setEnterAgain(false); setEnterAgain(true);
}} }}
> >
<BillFormContainer {t("general.actions.saveandnew")}
form={form} </Button>
disableInvNumber={billEnterModal.context.disableInvNumber} )}
/> </Space>
</Form> }
</Modal> destroyOnClose
); >
<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, mapDispatchToProps)(BillEnterModalContainer);
mapStateToProps,
mapDispatchToProps
)(BillEnterModalContainer);

View File

@@ -1,136 +1,114 @@
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, discount, form, responsibilityCenters, disabled }) {
lineData, const [search, setSearch] = useState("");
discount, const { t } = useTranslation();
form, const columns = [
responsibilityCenters, {
disabled, title: t("joblines.fields.line_desc"),
}) { dataIndex: "line_desc",
const [search, setSearch] = useState(""); key: "line_desc",
const {t} = useTranslation(); width: "10%",
const columns = [ 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: [
{ {
title: t("joblines.fields.line_desc"), text: t("jobs.labels.partsfilter"),
dataIndex: "line_desc", value: ["PAN", "PAP", "PAL", "PAA", "PAS", "PASL"]
key: "line_desc",
width: "10%",
sorter: (a, b) => alphaSort(a.line_desc, b.line_desc),
}, },
{ {
title: t("joblines.fields.oem_partno"), text: t("joblines.fields.part_types.PAN"),
dataIndex: "oem_partno", value: ["PAN", "PAP"]
key: "oem_partno",
width: "10%",
sorter: (a, b) => alphaSort(a.oem_partno, b.oem_partno),
}, },
{ {
title: t("joblines.fields.part_type"), text: t("joblines.fields.part_types.PAL"),
dataIndex: "part_type", value: ["PAL"]
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.act_price}
? record.prt_dsmk_m </CurrencyFormatter>
: record.act_price} {record.part_qty ? `(x ${record.part_qty})` : null}
</CurrencyFormatter> {record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? (
{record.part_qty ? `(x ${record.part_qty})` : null} <span style={{ marginLeft: ".2rem" }}>{`(${record.prt_dsmk_p}%)`}</span>
{record.prt_dsmk_p && record.prt_dsmk_p !== 0 ? ( ) : (
<span <></>
style={{marginLeft: ".2rem"}} )}
>{`(${record.prt_dsmk_p}%)`}</span> </>
) : ( )
<></> },
)} {
</> title: t("billlines.fields.posting"),
), dataIndex: "posting",
}, key: "posting",
{
title: t("billlines.fields.posting"),
dataIndex: "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,284 +1,216 @@
import React from "react"; import React from "react";
import {MinusCircleFilled, PlusCircleFilled, WarningOutlined,} from "@ant-design/icons"; import { MinusCircleFilled, PlusCircleFilled, WarningOutlined } from "@ant-design/icons";
import {Button, Form, Input, InputNumber, Select, Space, Switch} from "antd"; import { Button, Form, Input, InputNumber, Select, Space, Switch } from "antd";
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";
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, mapDispatchToProps)(BillFormItemsExtendedFormItem);
mapStateToProps,
mapDispatchToProps
)(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 (
<Space wrap> <Button
<Form.Item onClick={() => {
label={t("billlines.fields.line_desc")} const values = form.getFieldsValue("billlineskeys");
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/>; form.setFieldsValue({
return <WarningOutlined style={{color: "red"}}/>; ...values,
}} billlineskeys: {
</Form.Item> ...(values.billlineskeys || {}),
<Form.Item [record.id]: {
label={t("billlines.fields.cost_center")} joblineid: record.id,
name={["billlineskeys", record.id, "cost_center"]} line_desc: record.line_desc,
> quantity: record.part_qty || 1,
<Select showSearch style={{minWidth: "3rem"}} disabled={disabled}> actual_price: record.act_price,
{bodyshop.cdk_dealerid || bodyshop.pbs_serialnumber cost_center: record.part_type
? CiecaSelect(true, false) ? bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid
: responsibilityCenters.costs.map((item) => ( ? record.part_type
<Select.Option key={item.name}>{item.name}</Select.Option> : responsibilityCenters.defaults && (responsibilityCenters.defaults.costs[record.part_type] || null)
))} : null
</Select> }
</Form.Item> }
<Form.Item });
label={t("billlines.fields.location")} }}
name={["billlineskeys", record.id, "location"]} >
> <PlusCircleFilled />
<Select disabled={disabled}> </Button>
{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>
); );
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>
);
} }

View File

@@ -1,30 +1,30 @@
import Icon, { UploadOutlined } from '@ant-design/icons'; import Icon, { UploadOutlined } from "@ant-design/icons";
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from "@apollo/client";
import { useSplitTreatments } from '@splitsoftware/splitio-react'; import { useSplitTreatments } from "@splitsoftware/splitio-react";
import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } from 'antd'; import { Alert, Divider, Form, Input, Select, Space, Statistic, Switch, Upload } 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 { MdOpenInNew } from 'react-icons/md'; import { MdOpenInNew } from "react-icons/md";
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 { CHECK_BILL_INVOICE_NUMBER } from '../../graphql/bills.queries'; import { CHECK_BILL_INVOICE_NUMBER } from "../../graphql/bills.queries";
import { selectBodyshop } from '../../redux/user/user.selectors'; import { selectBodyshop } from "../../redux/user/user.selectors";
import dayjs from '../../utils/day'; import dayjs from "../../utils/day";
import InstanceRenderManager from '../../utils/instanceRenderMgr'; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import AlertComponent from '../alert/alert.component'; import AlertComponent from "../alert/alert.component";
import BillFormLinesExtended from '../bill-form-lines-extended/bill-form-lines-extended.component'; import BillFormLinesExtended from "../bill-form-lines-extended/bill-form-lines-extended.component";
import FormDatePicker from '../form-date-picker/form-date-picker.component'; import FormDatePicker from "../form-date-picker/form-date-picker.component";
import FormFieldsChanged from '../form-fields-changed-alert/form-fields-changed-alert.component'; import FormFieldsChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
import CurrencyInput from '../form-items-formatted/currency-form-item.component'; import CurrencyInput from "../form-items-formatted/currency-form-item.component";
import JobSearchSelect from '../job-search-select/job-search-select.component'; import JobSearchSelect from "../job-search-select/job-search-select.component";
import LayoutFormRow from '../layout-form-row/layout-form-row.component'; import LayoutFormRow from "../layout-form-row/layout-form-row.component";
import VendorSearchSelect from '../vendor-search-select/vendor-search-select.component'; import VendorSearchSelect from "../vendor-search-select/vendor-search-select.component";
import BillFormLines from './bill-form.lines.component'; import BillFormLines from "./bill-form.lines.component";
import { CalculateBillTotal } from './bill-form.totals.utility'; import { CalculateBillTotal } from "./bill-form.totals.utility";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop
}); });
const mapDispatchToProps = (dispatch) => ({}); const mapDispatchToProps = (dispatch) => ({});
@@ -41,18 +41,18 @@ export function BillFormComponent({
job, job,
loadOutstandingReturns, loadOutstandingReturns,
loadInventory, loadInventory,
preferredMake, preferredMake
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
const [discount, setDiscount] = useState(0); const [discount, setDiscount] = useState(0);
const { const {
treatments: { Extended_Bill_Posting, ClosingPeriod }, treatments: { Extended_Bill_Posting, ClosingPeriod }
} = useSplitTreatments({ } = useSplitTreatments({
attributes: {}, attributes: {},
names: ['Extended_Bill_Posting', 'ClosingPeriod'], names: ["Extended_Bill_Posting", "ClosingPeriod"],
splitKey: bodyshop.imexshopid, splitKey: bodyshop.imexshopid
}); });
const handleVendorSelect = (props, opt) => { const handleVendorSelect = (props, opt) => {
@@ -62,16 +62,16 @@ export function BillFormComponent({
!billEdit && !billEdit &&
loadOutstandingReturns({ loadOutstandingReturns({
variables: { variables: {
jobId: form.getFieldValue('jobid'), jobId: form.getFieldValue("jobid"),
vendorId: opt.value, vendorId: opt.value
}, }
}); });
}; };
const handleFederalTaxExemptSwitchToggle = (checked) => { const handleFederalTaxExemptSwitchToggle = (checked) => {
// Early gate // Early gate
if (!checked) return; if (!checked) return;
const values = form.getFieldsValue('billlines'); const values = form.getFieldsValue("billlines");
// Gate bill lines // Gate bill lines
if (!values?.billlines?.length) return; if (!values?.billlines?.length) return;
@@ -83,26 +83,26 @@ export function BillFormComponent({
}; };
useEffect(() => { useEffect(() => {
if (job) form.validateFields(['is_credit_memo']); if (job) form.validateFields(["is_credit_memo"]);
}, [job, form]); }, [job, form]);
useEffect(() => { useEffect(() => {
const vendorId = form.getFieldValue('vendorid'); const vendorId = form.getFieldValue("vendorid");
if (vendorId && vendorAutoCompleteOptions) { if (vendorId && vendorAutoCompleteOptions) {
const matchingVendors = vendorAutoCompleteOptions.filter((v) => v.id === vendorId); const matchingVendors = vendorAutoCompleteOptions.filter((v) => v.id === vendorId);
if (matchingVendors.length === 1) { if (matchingVendors.length === 1) {
setDiscount(matchingVendors[0].discount); setDiscount(matchingVendors[0].discount);
} }
} }
const jobId = form.getFieldValue('jobid'); const jobId = form.getFieldValue("jobid");
if (jobId) { if (jobId) {
loadLines({ variables: { id: jobId } }); loadLines({ variables: { id: jobId } });
if (form.getFieldValue('is_credit_memo') && vendorId && !billEdit) { if (form.getFieldValue("is_credit_memo") && vendorId && !billEdit) {
loadOutstandingReturns({ loadOutstandingReturns({
variables: { variables: {
jobId: jobId, jobId: jobId,
vendorId: vendorId, vendorId: vendorId
}, }
}); });
} }
} }
@@ -118,24 +118,24 @@ export function BillFormComponent({
setDiscount, setDiscount,
vendorAutoCompleteOptions, vendorAutoCompleteOptions,
loadLines, loadLines,
bodyshop.inhousevendorid, bodyshop.inhousevendorid
]); ]);
return ( return (
<div> <div>
<FormFieldsChanged form={form} /> <FormFieldsChanged form={form} />
<Form.Item style={{ display: 'none' }} name="isinhouse" valuePropName="checked"> <Form.Item style={{ display: "none" }} name="isinhouse" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<LayoutFormRow grow> <LayoutFormRow grow>
<Form.Item <Form.Item
name="jobid" name="jobid"
label={t('bills.fields.ro_number')} label={t("bills.fields.ro_number")}
rules={[ rules={[
{ {
required: true, required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, }
]} ]}
> >
<JobSearchSelect <JobSearchSelect
@@ -143,20 +143,14 @@ export function BillFormComponent({
convertedOnly convertedOnly
notExported={false} notExported={false}
onBlur={() => { onBlur={() => {
if ( if (form.getFieldValue("jobid") !== null && form.getFieldValue("jobid") !== undefined) {
form.getFieldValue('jobid') !== null && loadLines({ variables: { id: form.getFieldValue("jobid") } });
form.getFieldValue('jobid') !== undefined if (form.getFieldValue("vendorid") !== null && form.getFieldValue("vendorid") !== undefined) {
) {
loadLines({ variables: { id: form.getFieldValue('jobid') } });
if (
form.getFieldValue('vendorid') !== null &&
form.getFieldValue('vendorid') !== undefined
) {
loadOutstandingReturns({ loadOutstandingReturns({
variables: { variables: {
jobId: form.getFieldValue('jobid'), jobId: form.getFieldValue("jobid"),
vendorId: form.getFieldValue('vendorid'), vendorId: form.getFieldValue("vendorid")
}, }
}); });
} }
} }
@@ -164,22 +158,22 @@ export function BillFormComponent({
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('bills.fields.vendor')} label={t("bills.fields.vendor")}
name="vendorid" name="vendorid"
// style={{ display: billEdit ? "none" : null }} // style={{ display: billEdit ? "none" : null }}
rules={[ rules={[
{ {
required: true, required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(rule, value) { validator(rule, value) {
if (value && !getFieldValue(['isinhouse']) && value === bodyshop.inhousevendorid) { if (value && !getFieldValue(["isinhouse"]) && value === bodyshop.inhousevendorid) {
return Promise.reject(t('bills.validation.manualinhouse')); return Promise.reject(t("bills.validation.manualinhouse"));
} }
return Promise.resolve(); return Promise.resolve();
}, }
}), })
]} ]}
> >
<VendorSearchSelect <VendorSearchSelect
@@ -199,12 +193,8 @@ export function BillFormComponent({
type="warning" type="warning"
message={ message={
<Space> <Space>
{t('bills.labels.iouexists')} {t("bills.labels.iouexists")}
<Link <Link target="_blank" rel="noopener noreferrer" to={`/manage/jobs/${iou.id}?tab=repairdata`}>
target="_blank"
rel="noopener noreferrer"
to={`/manage/jobs/${iou.id}?tab=repairdata`}
>
<Space> <Space>
{iou.ro_number} {iou.ro_number}
<Icon component={MdOpenInNew} /> <Icon component={MdOpenInNew} />
@@ -216,89 +206,85 @@ export function BillFormComponent({
))} ))}
<LayoutFormRow> <LayoutFormRow>
<Form.Item <Form.Item
label={t('bills.fields.invoice_number')} label={t("bills.fields.invoice_number")}
name="invoice_number" name="invoice_number"
validateTrigger="onBlur" validateTrigger="onBlur"
hasFeedback hasFeedback
rules={[ rules={[
{ {
required: true, required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
async validator(rule, value) { async validator(rule, value) {
const vendorid = getFieldValue('vendorid'); const vendorid = getFieldValue("vendorid");
if (vendorid && value) { if (vendorid && value) {
const response = await client.query({ const response = await client.query({
query: CHECK_BILL_INVOICE_NUMBER, query: CHECK_BILL_INVOICE_NUMBER,
variables: { variables: {
invoice_number: value, invoice_number: value,
vendorid: vendorid, vendorid: vendorid
}, }
}); });
if (response.data.bills_aggregate.aggregate.count === 0) { if (response.data.bills_aggregate.aggregate.count === 0) {
return Promise.resolve(); return Promise.resolve();
} else if ( } else if (
response.data.bills_aggregate.nodes.length === 1 && response.data.bills_aggregate.nodes.length === 1 &&
response.data.bills_aggregate.nodes[0].id === form.getFieldValue('id') response.data.bills_aggregate.nodes[0].id === form.getFieldValue("id")
) { ) {
return Promise.resolve(); return Promise.resolve();
} }
return Promise.reject(t('bills.validation.unique_invoice_number')); return Promise.reject(t("bills.validation.unique_invoice_number"));
} else { } else {
return Promise.resolve(); return Promise.resolve();
} }
}, }
}), })
]} ]}
> >
<Input disabled={disabled || disableInvNumber} /> <Input disabled={disabled || disableInvNumber} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('bills.fields.date')} label={t("bills.fields.date")}
name="date" name="date"
rules={[ rules={[
{ {
required: true, required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(rule, value) { validator(rule, value) {
if (ClosingPeriod.treatment === 'on' && bodyshop.accountingconfig.ClosingPeriod) { if (ClosingPeriod.treatment === "on" && bodyshop.accountingconfig.ClosingPeriod) {
if ( if (
dayjs(value) dayjs(value)
.startOf('day') .startOf("day")
.isSameOrAfter( .isSameOrAfter(dayjs(bodyshop.accountingconfig.ClosingPeriod[0]).startOf("day")) &&
dayjs(bodyshop.accountingconfig.ClosingPeriod[0]).startOf('day')
) &&
dayjs(value) dayjs(value)
.startOf('day') .startOf("day")
.isSameOrBefore( .isSameOrBefore(dayjs(bodyshop.accountingconfig.ClosingPeriod[1]).endOf("day"))
dayjs(bodyshop.accountingconfig.ClosingPeriod[1]).endOf('day')
)
) { ) {
return Promise.resolve(); return Promise.resolve();
} else { } else {
return Promise.reject(t('bills.validation.closingperiod')); return Promise.reject(t("bills.validation.closingperiod"));
} }
} else { } else {
return Promise.resolve(); return Promise.resolve();
} }
}, }
}), })
]} ]}
> >
<FormDatePicker disabled={disabled} /> <FormDatePicker disabled={disabled} />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('bills.fields.is_credit_memo')} label={t("bills.fields.is_credit_memo")}
name="is_credit_memo" name="is_credit_memo"
valuePropName="checked" valuePropName="checked"
rules={[ rules={[
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(rule, value) { validator(rule, value) {
if (value === true && getFieldValue('jobid') && getFieldValue('vendorid')) { if (value === true && getFieldValue("jobid") && getFieldValue("vendorid")) {
//Removed as this would cause an additional reload when validating the form on submit and clear the values. //Removed as this would cause an additional reload when validating the form on submit and clear the values.
// loadOutstandingReturns({ // loadOutstandingReturns({
// variables: { // variables: {
@@ -316,31 +302,31 @@ export function BillFormComponent({
job.status === bodyshop.md_ro_statuses.default_void) && job.status === bodyshop.md_ro_statuses.default_void) &&
(value === false || !value) (value === false || !value)
) { ) {
return Promise.reject(t('bills.labels.onlycmforinvoiced')); return Promise.reject(t("bills.labels.onlycmforinvoiced"));
} }
return Promise.resolve(); return Promise.resolve();
}, }
}), })
]} ]}
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={t('bills.fields.total')} label={t("bills.fields.total")}
name="total" name="total"
rules={[ rules={[
{ {
required: true, required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
}, }
]} ]}
> >
<CurrencyInput min={0} disabled={disabled} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
{!billEdit && ( {!billEdit && (
<Form.Item label={t('bills.fields.allpartslocation')} name="location"> <Form.Item label={t("bills.fields.allpartslocation")} name="location">
<Select style={{ width: '10rem' }} disabled={disabled} allowClear> <Select style={{ width: "10rem" }} disabled={disabled} allowClear>
{bodyshop.md_parts_locations.map((loc, idx) => ( {bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}> <Select.Option key={idx} value={loc}>
{loc} {loc}
@@ -353,40 +339,36 @@ export function BillFormComponent({
<LayoutFormRow> <LayoutFormRow>
{InstanceRenderManager({ {InstanceRenderManager({
imex: ( imex: (
<Form.Item span={3} label={t('bills.fields.federal_tax_rate')} name="federal_tax_rate"> <Form.Item span={3} label={t("bills.fields.federal_tax_rate")} name="federal_tax_rate">
<CurrencyInput min={0} disabled={disabled} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
), )
})} })}
<Form.Item span={3} label={t('bills.fields.state_tax_rate')} name="state_tax_rate"> <Form.Item span={3} label={t("bills.fields.state_tax_rate")} name="state_tax_rate">
<CurrencyInput min={0} disabled={disabled} /> <CurrencyInput min={0} disabled={disabled} />
</Form.Item> </Form.Item>
{InstanceRenderManager({ {InstanceRenderManager({
imex: ( imex: (
<> <>
<Form.Item span={3} label={t('bills.fields.local_tax_rate')} name="local_tax_rate"> <Form.Item span={3} label={t("bills.fields.local_tax_rate")} name="local_tax_rate">
<CurrencyInput min={0} /> <CurrencyInput min={0} />
</Form.Item> </Form.Item>
{bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? ( {bodyshop.pbs_serialnumber || bodyshop.cdk_dealerid ? (
<Form.Item <Form.Item span={2} label={t("bills.labels.federal_tax_exempt")} name="federal_tax_exempt">
span={2}
label={t('bills.labels.federal_tax_exempt')}
name="federal_tax_exempt"
>
<Switch onChange={handleFederalTaxExemptSwitchToggle} /> <Switch onChange={handleFederalTaxExemptSwitchToggle} />
</Form.Item> </Form.Item>
) : null} ) : null}
</> </>
), )
})} })}
<Form.Item shouldUpdate span={13}> <Form.Item shouldUpdate span={13}>
{() => { {() => {
const values = form.getFieldsValue([ const values = form.getFieldsValue([
'billlines', "billlines",
'total', "total",
'federal_tax_rate', "federal_tax_rate",
'state_tax_rate', "state_tax_rate",
'local_tax_rate', "local_tax_rate"
]); ]);
let totals; let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0) if (!!values.total && !!values.billlines && values.billlines.length > 0)
@@ -394,56 +376,48 @@ export function BillFormComponent({
if (!!totals) if (!!totals)
return ( return (
<div align="right"> <div align="right">
<Space size='large' wrap> <Space size="large" wrap>
<Statistic <Statistic title={t("bills.labels.subtotal")} value={totals.subtotal.toFormat()} precision={2} />
title={t('bills.labels.subtotal')}
value={totals.subtotal.toFormat()}
precision={2}
/>
{InstanceRenderManager({ {InstanceRenderManager({
imex: ( imex: (
<Statistic <Statistic
title={t('bills.labels.federal_tax')} title={t("bills.labels.federal_tax")}
value={totals.federalTax.toFormat()} value={totals.federalTax.toFormat()}
precision={2} precision={2}
/> />
), )
})} })}
<Statistic <Statistic title={t("bills.labels.state_tax")} value={totals.stateTax.toFormat()} precision={2} />
title={t('bills.labels.state_tax')}
value={totals.stateTax.toFormat()}
precision={2}
/>
{InstanceRenderManager({ {InstanceRenderManager({
imex: ( imex: (
<Statistic <Statistic
title={t('bills.labels.local_tax')} title={t("bills.labels.local_tax")}
value={totals.localTax.toFormat()} value={totals.localTax.toFormat()}
precision={2} precision={2}
/> />
), )
})} })}
<Statistic <Statistic
title={t('bills.labels.entered_total')} title={t("bills.labels.entered_total")}
value={totals.enteredTotal.toFormat()} value={totals.enteredTotal.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t('bills.labels.bill_total')} title={t("bills.labels.bill_total")}
value={totals.invoiceTotal.toFormat()} value={totals.invoiceTotal.toFormat()}
precision={2} precision={2}
/> />
<Statistic <Statistic
title={t('bills.labels.discrepancy')} title={t("bills.labels.discrepancy")}
valueStyle={{ valueStyle={{
color: totals.discrepancy.getAmount() === 0 ? 'green' : 'red', color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
}} }}
value={totals.discrepancy.toFormat()} value={totals.discrepancy.toFormat()}
precision={2} precision={2}
/> />
</Space> </Space>
{form.getFieldValue('is_credit_memo') ? ( {form.getFieldValue("is_credit_memo") ? (
<AlertComponent type="warning" message={t('bills.labels.enteringcreditmemo')} /> <AlertComponent type="warning" message={t("bills.labels.enteringcreditmemo")} />
) : null} ) : null}
</div> </div>
); );
@@ -451,9 +425,9 @@ export function BillFormComponent({
}} }}
</Form.Item> </Form.Item>
</LayoutFormRow> </LayoutFormRow>
<Divider orientation="left">{t('bills.labels.bill_lines')}</Divider> <Divider orientation="left">{t("bills.labels.bill_lines")}</Divider>
{Extended_Bill_Posting.treatment === 'on' ? ( {Extended_Bill_Posting.treatment === "on" ? (
<BillFormLinesExtended <BillFormLinesExtended
lineData={lineData} lineData={lineData}
discount={discount} discount={discount}
@@ -471,13 +445,13 @@ export function BillFormComponent({
billEdit={billEdit} billEdit={billEdit}
/> />
)} )}
<Divider orientation="left" style={{ display: billEdit ? 'none' : null }}> <Divider orientation="left" style={{ display: billEdit ? "none" : null }}>
{t('documents.labels.upload')} {t("documents.labels.upload")}
</Divider> </Divider>
<Form.Item <Form.Item
name="upload" name="upload"
label="Upload" label="Upload"
style={{ display: billEdit ? 'none' : null }} style={{ display: billEdit ? "none" : null }}
valuePropName="fileList" valuePropName="fileList"
getValueFromEvent={(e) => { getValueFromEvent={(e) => {
if (Array.isArray(e)) { if (Array.isArray(e)) {

View File

@@ -1,83 +1,67 @@
import {useLazyQuery, useQuery} from "@apollo/client"; import { useLazyQuery, useQuery } from "@apollo/client";
import {useSplitTreatments} 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, form, billEdit, disabled, disableInvNumber }) {
bodyshop, const {
form, treatments: { Simple_Inventory }
billEdit, } = useSplitTreatments({
disabled, attributes: {},
disableInvNumber, names: ["Simple_Inventory"],
}) { splitKey: bodyshop && bodyshop.imexshopid
const {treatments: {Simple_Inventory}} = useSplitTreatments({ });
attributes: {},
names: ["Simple_Inventory"],
splitKey: bodyshop && bodyshop.imexshopid,
});
const {data: VendorAutoCompleteData} = useQuery( const { data: VendorAutoCompleteData } = useQuery(SEARCH_VENDOR_AUTOCOMPLETE, {
SEARCH_VENDOR_AUTOCOMPLETE, fetchPolicy: "network-only",
{fetchPolicy: "network-only", nextFetchPolicy: "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 }] = useLazyQuery(QUERY_OUTSTANDING_INVENTORY);
const [loadInventory, {loading: inventoryLoading, data: inventoryData}] =
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}
} lineData={lineData ? lineData.joblines : []}
loadLines={loadLines} job={lineData ? lineData.jobs_by_pk : null}
lineData={lineData ? lineData.joblines : []} responsibilityCenters={bodyshop.md_responsibility_centers || null}
job={lineData ? lineData.jobs_by_pk : null} disableInvNumber={disableInvNumber}
responsibilityCenters={bodyshop.md_responsibility_centers || null} loadOutstandingReturns={loadOutstandingReturns}
disableInvNumber={disableInvNumber} loadInventory={loadInventory}
loadOutstandingReturns={loadOutstandingReturns} preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null}
loadInventory={loadInventory} />
preferredMake={lineData ? lineData.jobs_by_pk.v_make_desc : null} {!billEdit && <BillCmdReturnsTableComponent form={form} returnLoading={returnLoading} returnData={returnData} />}
/> {Simple_Inventory.treatment === "on" && (
{!billEdit && ( <BillInventoryTable
<BillCmdReturnsTableComponent form={form}
form={form} inventoryLoading={inventoryLoading}
returnLoading={returnLoading} inventoryData={billEdit ? [] : inventoryData}
returnData={returnData} billEdit={billEdit}
/> />
)} )}
{Simple_Inventory.treatment === "on" && ( </>
<BillInventoryTable );
form={form}
inventoryLoading={inventoryLoading}
inventoryData={billEdit ? [] : inventoryData}
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,42 @@
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) stateTax = stateTax.add(itemTotal.percentage(state_tax_rate || 0));
} if (i.applicable_taxes?.local) localTax = localTax.add(itemTotal.percentage(local_tax_rate || 0));
if (i.applicable_taxes?.state) }
stateTax = stateTax.add(itemTotal.percentage(state_tax_rate || 0)); });
if (i.applicable_taxes?.local)
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,153 @@
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, bodyshop, form, billEdit, inventoryLoading, inventoryData }) {
billEnterModal, const { t } = useTranslation();
bodyshop,
form,
billEdit,
inventoryLoading,
inventoryData,
}) {
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]);
return ( if (inventoryLoading) return <Skeleton />;
<Form.Item
shouldUpdate={(prev, cur) => prev.vendorid !== cur.vendorid}
noStyle
>
{() => {
const is_inhouse =
form.getFieldValue("vendorid") === bodyshop.inhousevendorid;
if (!is_inhouse || billEdit) { return (
return null; <Form.List name="inventory">
} {(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>
if (inventoryLoading) return <Skeleton/>; <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>
return ( <td>
<Form.List name="inventory"> <Form.Item
{(fields, {add, remove, move}) => { span={2}
return ( //label={t("joblines.fields.mod_lb_hrs")}
<> key={`${index}consumefrominventory`}
<Typography.Title level={4}> name={[field.name, "consumefrominventory"]}
{t("inventory.labels.inventory")} valuePropName="checked"
</Typography.Title> >
<table className="bill-inventory-table"> <Checkbox />
<thead> </Form.Item>
<tr> </td>
<th>{t("billlines.fields.line_desc")}</th> </tr>
<th>{t("vendors.fields.name")}</th> ))}
<th>{t("billlines.fields.quantity")}</th> </tbody>
<th>{t("billlines.fields.actual_price")}</th> </table>
<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.Item> </Form.List>
); );
}}
</Form.Item>
);
} }

View File

@@ -16,4 +16,4 @@
tr:hover { tr:hover {
background-color: #f5f5f5; background-color: #f5f5f5;
} }
} }

View File

@@ -1,98 +1,73 @@
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";
import InstanceRenderMgr from '../../utils/instanceRenderMgr'; import InstanceRenderMgr from "../../utils/instanceRenderMgr";
//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 }, ref) => {
{options, disabled, allowRemoved, ...restProps}, const { t } = useTranslation();
ref
) => {
const {t} = useTranslation();
return ( return (
<Select <Select
disabled={disabled} disabled={disabled}
ref={ref} ref={ref}
showSearch showSearch
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
optionLabelProp={"name"} optionLabelProp={"name"}
// optionFilterProp="line_desc" // optionFilterProp="line_desc"
filterOption={(inputValue, option) => { filterOption={(inputValue, option) => {
return ( return (
(option.line_desc && (option.line_desc && option.line_desc.toLowerCase().includes(inputValue.toLowerCase())) ||
option.line_desc (option.oem_partno && option.oem_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
.toLowerCase() (option.alt_partno && option.alt_partno.toLowerCase().includes(inputValue.toLowerCase())) ||
.includes(inputValue.toLowerCase())) || (option.act_price && option.act_price.toString().startsWith(inputValue.toString()))
(option.oem_partno && );
option.oem_partno }}
.toLowerCase() notFoundContent={"Removed."}
.includes(inputValue.toLowerCase())) || {...restProps}
(option.alt_partno && >
option.alt_partno <Select.Option key={null} value={"noline"} cost={0} line_desc={""}>
.toLowerCase() {t("billlines.labels.other")}
.includes(inputValue.toLowerCase())) || </Select.Option>
(option.act_price && {options
option.act_price.toString().startsWith(inputValue.toString())) ? options.map((item) => (
); <Option
}} disabled={allowRemoved ? false : item.removed}
notFoundContent={"Removed."} key={item.id}
{...restProps} value={item.id}
> cost={item.act_price ? item.act_price : 0}
<Select.Option key={null} value={"noline"} cost={0} line_desc={""}> part_type={item.part_type}
{t("billlines.labels.other")} line_desc={item.line_desc}
</Select.Option> part_qty={item.part_qty}
{options oem_partno={item.oem_partno}
? options.map((item) => ( alt_partno={item.alt_partno}
<Option act_price={item.act_price}
disabled={allowRemoved ? false : item.removed} style={{
key={item.id} ...(item.removed ? { textDecoration: "line-through" } : {})
value={item.id} }}
cost={item.act_price ? item.act_price : 0} name={`${item.removed ? `(REMOVED) ` : ""}${item.line_desc}${
part_type={item.part_type} item.oem_partno ? ` - ${item.oem_partno}` : ""
line_desc={item.line_desc} }${item.alt_partno ? ` (${item.alt_partno})` : ""}`.trim()}
part_qty={item.part_qty} >
oem_partno={item.oem_partno}
alt_partno={item.alt_partno}
act_price={item.act_price}
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>
{ {InstanceRenderMgr({
InstanceRenderMgr rome: item.act_price === 0 && item.mod_lb_hrs > 0 && (
( <span style={{ float: "right", paddingleft: "1rem" }}>{`${item.mod_lb_hrs} units`}</span>
{ )
rome: item.act_price === 0 && item.mod_lb_hrs > 0 && ( })}
<span style={{float: "right", paddingleft: "1rem"}}>
{`${item.mod_lb_hrs} units`}
</span>
)
} <span style={{ float: "right", paddingleft: "1rem" }}>
) {item.act_price ? `$${item.act_price && item.act_price.toFixed(2)}` : ``}
}
<span style={{float: "right", paddingleft: "1rem"}}>
{item.act_price
? `$${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,97 +1,89 @@
import {gql, useMutation} from "@apollo/client"; import { gql, useMutation } from "@apollo/client";
import {Button, notification} from "antd"; import { Button, notification } 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 {selectAuthLevel, selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors"; import { selectAuthLevel, selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import {HasRbacAccess} from "../rbac-wrapper/rbac-wrapper.component"; import { HasRbacAccess } from "../rbac-wrapper/rbac-wrapper.component";
import {INSERT_EXPORT_LOG} from "../../graphql/accounting.queries"; 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, mapDispatchToProps)(BillMarkExportedButton);
mapStateToProps,
mapDispatchToProps
)(BillMarkExportedButton);
export function BillMarkExportedButton({ export function BillMarkExportedButton({ currentUser, bodyshop, authLevel, bill }) {
currentUser, const { t } = useTranslation();
bodyshop, const [loading, setLoading] = useState(false);
authLevel, const [insertExportLog] = useMutation(INSERT_EXPORT_LOG);
bill,
}) {
const {t} = useTranslation();
const [loading, setLoading] = useState(false);
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",
}); });
if (hasAccess) await insertExportLog({
return ( variables: {
<Button loading={loading} disabled={bill.exported} onClick={handleUpdate}> logs: [
{t("bills.labels.markexported")} {
</Button> bodyshopid: bodyshop.id,
); billid: bill.id,
successful: true,
message: JSON.stringify([t("general.labels.markedexported")]),
useremail: currentUser.email
}
]
}
});
return <></>; 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"
});
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,79 +1,72 @@
import {gql, useMutation} from "@apollo/client"; import { gql, useMutation } from "@apollo/client";
import {Button, notification} from "antd"; import { Button, notification } 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 {selectAuthLevel, selectBodyshop,} from "../../redux/user/user.selectors"; import { selectAuthLevel, selectBodyshop } from "../../redux/user/user.selectors";
import {HasRbacAccess} from "../rbac-wrapper/rbac-wrapper.component"; 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, mapDispatchToProps)(BillMarkForReexportButton);
mapStateToProps,
mapDispatchToProps
)(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 (hasAccess) if (!result.errors) {
return ( notification["success"]({
<Button message: t("bills.successes.reexport")
loading={loading} });
disabled={!bill.exported} } else {
onClick={handleUpdate} notification["error"]({
> message: t("bills.errors.saving", {
{t("bills.labels.markforreexport")} error: JSON.stringify(result.errors)
</Button> })
); });
}
setLoading(false);
//Get the owner details, populate it all back into the job.
};
return <></>; const hasAccess = HasRbacAccess({
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,151 +1,136 @@
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 dayjs from "./../../utils/day"; 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 {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import {CalculateBillTotal} from "../bill-form/bill-form.totals.utility"; 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, mapDispatchToProps)(BilllineAddInventory);
mapStateToProps,
mapDispatchToProps
)(BilllineAddInventory);
export function BilllineAddInventory({ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) {
currentUser, const [loading, setLoading] = useState(false);
bodyshop, const { billid } = queryString.parse(useLocation().search);
billline, const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
disabled,
jobid,
}) {
const [loading, setLoading] = useState(false);
const {billid} = queryString.parse(useLocation().search);
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const addToInventory = async () => { const addToInventory = async () => {
setLoading(true); setLoading(true);
//Check to make sure there are no existing items already in the inventory. //Check to make sure there are no existing items already in the inventory.
const cm = { const cm = {
vendorid: bodyshop.inhousevendorid, vendorid: bodyshop.inhousevendorid,
invoice_number: "ih", invoice_number: "ih",
jobid: jobid, jobid: jobid,
isinhouse: true, isinhouse: true,
is_credit_memo: true, is_credit_memo: true,
date: dayjs().format("YYYY-MM-DD"), date: dayjs().format("YYYY-MM-DD"),
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate, federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate, state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate, local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
total: 0, total: 0,
billlines: [ billlines: [
{ {
actual_price: billline.actual_price, actual_price: billline.actual_price,
actual_cost: billline.actual_cost, actual_cost: billline.actual_cost,
quantity: billline.quantity, quantity: billline.quantity,
line_desc: billline.line_desc, line_desc: billline.line_desc,
cost_center: billline.cost_center, cost_center: billline.cost_center,
deductedfromlbr: billline.deductedfromlbr, deductedfromlbr: billline.deductedfromlbr,
applicable_taxes: { applicable_taxes: {
local: billline.applicable_taxes.local, local: billline.applicable_taxes.local,
state: billline.applicable_taxes.state, state: billline.applicable_taxes.state,
federal: billline.applicable_taxes.federal, federal: billline.applicable_taxes.federal
}, }
},
],
};
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
const insertResult = await insertInventoryLine({
variables: {
joblineId:
billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line.
//Unfortunately, we can't send null as the GQL syntax validation fails.
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
billlineid: billline.id,
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc,
},
cm: {...cm, billlines: {data: cm.billlines}}, //Fix structure for apollo insert.
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
deliver_by: dayjs().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 ( cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
<Tooltip title={t("inventory.actions.addtoinventory")}>
<Button const insertResult = await insertInventoryLine({
loading={loading} variables: {
disabled={ joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line.
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: dayjs().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,236 +1,209 @@
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) => dispatch(setModalContext({ context: context, modal: "billEnter" })),
setBillEnterContext: (context) => setReconciliationContext: (context) => dispatch(setModalContext({ context: context, modal: "reconciliation" }))
dispatch(setModalContext({context: context, modal: "billEnter"})),
setReconciliationContext: (context) =>
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} jobid={job.id} />
<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>
{showView && ( <Button onClick={() => refetch()}>
<Button onClick={() => handleOnRowClick(record)}> <SyncOutlined />
<EditFilled /> </Button>
</Button> {job && job.converted ? (
)} <>
<BillDeleteButton bill={record} jobid={job.id} /> <Button
<BillDetailEditReturnComponent onClick={() => {
data={{ bills_by_pk: { ...record, jobid: job.id } }} setBillEnterContext({
disabled={ actions: { refetch: billsQuery.refetch },
record.is_credit_memo || context: {
record.vendorid === bodyshop.inhousevendorid || job
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>
<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" {t("jobs.actions.postbills")}
dataSource={filteredBills} </Button>
onChange={handleTableChange} <Button
/> onClick={() => {
</Card> 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, mapDispatchToProps)(BillsListTableComponent);
mapStateToProps,
mapDispatchToProps
)(BillsListTableComponent);

View File

@@ -1,121 +1,112 @@
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 {useLocation, useNavigate} from "react-router-dom"; import { useLocation, useNavigate } from "react-router-dom";
import {Input, Table} 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 = useNavigate(); 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],
title: t("vendors.fields.cost_center"), type: "radio"
dataIndex: "cost_center", }}
key: "cost_center", onRow={(record, rowIndex) => {
sorter: (a, b) => alphaSort(a.cost_center, b.cost_center), return {
sortOrder: onClick: (event) => {
state.sortedInfo.columnKey === "cost_center" && state.sortedInfo.order, handleOnRowClick(record);
}, } // 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,64 +1,63 @@
import {HomeFilled} from "@ant-design/icons"; import { HomeFilled } from "@ant-design/icons";
import {Breadcrumb, Col, Row} 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 {useSplitTreatments} 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 {
const {treatments: {OpenSearch}} = useSplitTreatments({ treatments: { OpenSearch }
attributes: {}, } = useSplitTreatments({
names: ["OpenSearch"], attributes: {},
splitKey: bodyshop && bodyshop.imexshopid, names: ["OpenSearch"],
}); splitKey: bodyshop && bodyshop.imexshopid
// TODO - Client Update - Technically key is not doing anything here });
return ( // TODO - Client Update - Technically key is not doing anything here
<Row className="breadcrumb-container"> return (
<Col xs={24} sm={24} md={16}> <Row className="breadcrumb-container">
<Breadcrumb <Col xs={24} sm={24} md={16}>
separator=">" <Breadcrumb
items={[ separator=">"
{ items={[
key: "home", {
title: ( key: "home",
<Link to={`/manage/`}> title: (
<HomeFilled/>{" "} <Link to={`/manage/`}>
{(bodyshop && bodyshop.shopname && `(${bodyshop.shopname})`) || <HomeFilled /> {(bodyshop && bodyshop.shopname && `(${bodyshop.shopname})`) || ""}
""} </Link>
</Link> )
), },
}, ...breadcrumbs.map((item) =>
...breadcrumbs.map((item) => item.link
item.link ? {
? { key: item.label,
key: item.label, title: <Link to={item.link}>{item.label}</Link>
title: <Link to={item.link}>{item.label}</Link>, }
} : {
: { key: item.label,
key: item.label, title: item.label
title: item.label, }
} )
), ]}
]} />
/> </Col>
</Col> <Col xs={24} sm={24} md={8}>
<Col xs={24} sm={24} md={8}> {OpenSearch.treatment === "on" ? <GlobalSearchOs /> : <GlobalSearch />}
{OpenSearch.treatment === "on" ? <GlobalSearchOs/> : <GlobalSearch/>} </Col>
</Col> </Row>
</Row> );
);
} }
export default connect(mapStateToProps, null)(BreadCrumbs); export default connect(mapStateToProps, null)(BreadCrumbs);

View File

@@ -1,99 +1,87 @@
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, toggleModalVisible }) {
caBcEtfTableModal, const { t } = useTranslation();
toggleModalVisible,
}) {
const {t} = useTranslation();
const {open} = 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"
);
setLoading(false);
};
useEffect(() => {
if (open) {
form.resetFields();
} }
}, [open, form]); },
{},
return ( values.sendby === "email" ? "e" : "p"
<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 (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>
);
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(ContractsFindModalContainer);
mapStateToProps,
mapDispatchToProps
)(ContractsFindModalContainer);

View File

@@ -1,42 +1,38 @@
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")} name="sendby" initialValue="print">
label={t("general.labels.sendby")} <Radio.Group>
name="sendby" <Radio value="email">{t("general.labels.email")}</Radio>
initialValue="print" <Radio value="print">{t("general.labels.print")}</Radio>
> </Radio.Group>
<Radio.Group> </Form.Item>
<Radio value="email">{t("general.labels.email")}</Radio> </div>
<Radio value="print">{t("general.labels.print")}</Radio> );
</Radio.Group>
</Form.Item>
</div>
);
} }

View File

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

View File

@@ -1,357 +1,328 @@
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 {Button, Card, Col, Form, Input, notification, Row, Space, Spin, Statistic,} from "antd"; import { Button, Card, Col, Form, Input, notification, Row, Space, Spin, Statistic } from "antd";
import axios from "axios"; import axios from "axios";
import dayjs from "../../utils/day"; 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 {INSERT_PAYMENT_RESPONSE, QUERY_RO_AND_OWNER_BY_JOB_PKS,} from "../../graphql/payment_response.queries"; import { INSERT_PAYMENT_RESPONSE, QUERY_RO_AND_OWNER_BY_JOB_PKS } from "../../graphql/payment_response.queries";
import {INSERT_NEW_PAYMENT} from "../../graphql/payments.queries"; import { INSERT_NEW_PAYMENT } from "../../graphql/payments.queries";
import {insertAuditTrail} from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
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 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, type}) => insertAuditTrail: ({ jobid, operation, type }) => dispatch(insertAuditTrail({ jobid, operation, type })),
dispatch(insertAuditTrail({jobid, operation, type})), toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment"))
toggleModalVisible: () => dispatch(toggleModalVisible("cardPayment")),
}); });
const CardPaymentModalComponent = ({ const CardPaymentModalComponent = ({ bodyshop, cardPaymentModal, toggleModalVisible, insertAuditTrail }) => {
bodyshop, const { context } = cardPaymentModal;
cardPaymentModal,
toggleModalVisible,
insertAuditTrail,
}) => {
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] },
{ skip: true
variables: {jobids: [context.jobid]}, });
skip: true,
console.log("🚀 ~ file: card-payment-modal.component..jsx:61 ~ data:", data);
//Initialize the intellipay window.
const SetIntellipayCallbackFunctions = () => {
console.log("*** Set IntelliPay callback functions.");
window.intellipay.runOnClose(() => {
//window.intellipay.initialize();
});
window.intellipay.runOnApproval(async function (response) {
console.warn("*** Running On Approval Script ***");
form.setFieldValue("paymentResponse", response);
form.submit();
});
window.intellipay.runOnNonApproval(async function (response) {
// Mutate unsuccessful payment
const { payments } = form.getFieldsValue();
await insertPaymentResponse({
variables: {
paymentResponse: payments.map((payment) => ({
amount: payment.amount,
bodyshopid: bodyshop.id,
jobid: payment.jobid,
declinereason: response.declinereason,
ext_paymentid: response.paymentid.toString(),
successful: false,
response
}))
} }
); });
console.log("🚀 ~ file: card-payment-modal.component..jsx:61 ~ data:", data); payments.forEach((payment) =>
//Initialize the intellipay window. insertAuditTrail({
const SetIntellipayCallbackFunctions = () => { jobid: payment.jobid,
console.log("*** Set IntelliPay callback functions."); operation: AuditTrailMapping.failedpayment(),
window.intellipay.runOnClose(() => { type: "failedpayment"
//window.intellipay.initialize(); })
}); );
});
};
window.intellipay.runOnApproval(async function (response) { const handleFinish = async (values) => {
console.warn("*** Running On Approval Script ***"); try {
form.setFieldValue("paymentResponse", response); await insertPayment({
form.submit(); 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: dayjs(Date.now()),
payment_responses: {
data: [
{
amount: payment.amount,
bodyshopid: bodyshop.id,
window.intellipay.runOnNonApproval(async function (response) { jobid: payment.jobid,
// Mutate unsuccessful payment declinereason: values.paymentResponse.declinereason,
ext_paymentid: values.paymentResponse.paymentid.toString(),
const {payments} = form.getFieldsValue(); successful: true,
response: values.paymentResponse
await insertPaymentResponse({ }
variables: { ]
paymentResponse: payments.map((payment) => ({
amount: payment.amount,
bodyshopid: bodyshop.id,
jobid: payment.jobid,
declinereason: response.declinereason,
ext_paymentid: response.paymentid.toString(),
successful: false,
response,
})),
},
});
payments.forEach((payment) =>
insertAuditTrail({
jobid: payment.jobid,
operation: AuditTrailMapping.failedpayment(),
type: "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: dayjs(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 () => {
setLoading(true);
//Validate
try {
await form.validateFields();
} catch (error) {
setLoading(false);
return;
}
try {
const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop,
refresh: !!window.intellipay,
});
if (window.intellipay) {
// eslint-disable-next-line no-eval
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", refetchQueries: ["GET_JOB_BY_PK"]
message: t("job_payments.notifications.error.openingip"), });
}); toggleModalVisible();
setLoading(false); } catch (error) {
} console.error(error);
}; notification.open({
type: "error",
message: t("payments.errors.inserting", { error: error.message })
});
} finally {
setLoading(false);
}
};
return ( const handleIntelliPayCharge = async () => {
<Card title="Card Payment"> setLoading(true);
<Spin spinning={loading}>
<Form
onFinish={handleFinish}
form={form}
layout="vertical"
initialValues={{
payments: context.jobid ? [{jobid: context.jobid}] : [],
}}
>
<Form.List name={["payments"]}>
{(fields, {add, remove, move}) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[
{
required: true,
//message: t("general.validation.required"),
},
]}
>
<JobSearchSelectComponent
notExported={false}
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 //Validate
shouldUpdate={(prevValues, curValues) => try {
prevValues.payments?.map((p) => p?.jobid).join() !== await form.validateFields();
curValues.payments?.map((p) => p?.jobid).join() } catch (error) {
} setLoading(false);
return;
}
try {
const response = await axios.post("/intellipay/lightbox_credentials", {
bodyshop,
refresh: !!window.intellipay
});
if (window.intellipay) {
// eslint-disable-next-line no-eval
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 (
<Card title="Card Payment">
<Spin spinning={loading}>
<Form
onFinish={handleFinish}
form={form}
layout="vertical"
initialValues={{
payments: context.jobid ? [{ jobid: context.jobid }] : []
}}
>
<Form.List name={["payments"]}>
{(fields, { add, remove, move }) => {
return (
<div>
{fields.map((field, index) => (
<Form.Item key={field.key}>
<Row gutter={[16, 16]}>
<Col span={16}>
<Form.Item
key={`${index}jobid`}
label={t("jobs.fields.ro_number")}
name={[field.name, "jobid"]}
rules={[
{
required: true
//message: t("general.validation.required"),
}
]}
>
<JobSearchSelectComponent notExported={false} 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")}
console.log("Updating the owner info section."); </Button>
//If all of the job ids have been fileld in, then query and update the IP field. </Form.Item>
const {payments} = form.getFieldsValue(); </div>
if ( );
payments?.length > 0 && }}
payments?.filter((p) => p?.jobid).length === payments?.length </Form.List>
) {
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
}
/>
<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
}
/>
</>
);
}}
</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 ( <Form.Item
<Space style={{float: "right"}}> shouldUpdate={(prevValues, curValues) =>
<Statistic prevValues.payments?.map((p) => p?.jobid).join() !== 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 (payments?.length > 0 && payments?.filter((p) => p?.jobid).length === payments?.length) {
type="hidden" console.log("**Calling refetch.");
value={totalAmountToCharge?.toFixed(2)} refetch({ jobids: payments.map((p) => p.jobid) });
/> }
<Button console.log(
type="primary" "Acc info",
// data-ipayname="submit" data,
className="ipayfield" payments && data && data.jobs.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null
loading={queryLoading || loading} );
disabled={!(totalAmountToCharge > 0)} return (
onClick={handleIntelliPayCharge} <>
> <Input
{t("job_payments.buttons.proceedtopayment")} className="ipayfield"
</Button> data-ipayname="account"
</Space> type="hidden"
); value={
}} payments && data && data.jobs.length > 0 ? data.jobs.map((j) => j.ro_number).join(", ") : null
</Form.Item> }
/>
<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
}
/>
</>
);
}}
</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);
{/* Lightbox payment response when it is completed */} return (
<Form.Item name="paymentResponse" hidden> <Space style={{ float: "right" }}>
<Input type="hidden"/> <Statistic title="Amount To Charge" value={totalAmountToCharge} precision={2} />
</Form.Item> <Input
</Form> className="ipayfield"
</Spin> data-ipayname="amount"
</Card> type="hidden"
); value={totalAmountToCharge?.toFixed(2)}
/>
<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, mapDispatchToProps)(CardPaymentModalComponent);
mapStateToProps,
mapDispatchToProps
)(CardPaymentModalComponent);

View File

@@ -1,57 +1,50 @@
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, toggleModalVisible, bodyshop }) {
cardPaymentModal, const { open } = cardPaymentModal;
toggleModalVisible, const { t } = useTranslation();
bodyshop,
}) {
const {open} = cardPaymentModal;
const {t} = useTranslation();
const handleCancel = () => { const handleCancel = () => {
toggleModalVisible(); toggleModalVisible();
}; };
const handleOK = () => { const handleOK = () => {
toggleModalVisible(); toggleModalVisible();
}; };
return ( return (
<Modal <Modal
open={open} 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, mapDispatchToProps)(CardPaymentModalContainer);
mapStateToProps,
mapDispatchToProps
)(CardPaymentModalContainer);

View File

@@ -1,100 +1,97 @@
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 {messaging, requestForToken} from "../../firebase/firebase.utils"; import { messaging, requestForToken } from "../../firebase/firebase.utils";
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";
export function ChatAffixContainer({bodyshop, chatVisible}) { export function ChatAffixContainer({ bodyshop, chatVisible }) {
const {t} = useTranslation(); const { t } = useTranslation();
const client = useApolloClient(); const client = useApolloClient();
useEffect(() => {
if (!bodyshop || !bodyshop.messagingservicesid) return;
async function SubscribeToTopic() { useEffect(() => {
try { if (!bodyshop || !bodyshop.messagingservicesid) return;
const r = await axios.post("/notifications/subscribe", {
fcm_tokens: await getToken(messaging, {
vapidKey: import.meta.env.VITE_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({
key: 'fcm',
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>
),
});
}
}
SubscribeToTopic(); async function SubscribeToTopic() {
// eslint-disable-next-line react-hooks/exhaustive-deps try {
}, [bodyshop]); const r = await axios.post("/notifications/subscribe", {
fcm_tokens: await getToken(messaging, {
vapidKey: import.meta.env.VITE_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({
key: "fcm",
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>
)
});
}
}
useEffect(() => { SubscribeToTopic();
function handleMessage(payload) { // eslint-disable-next-line react-hooks/exhaustive-deps
FcmHandler({ }, [bodyshop]);
client,
payload: (payload && payload.data && payload.data.data) || payload.data,
});
}
let stopMessageListener, channel; useEffect(() => {
try { function handleMessage(payload) {
stopMessageListener = onMessage(messaging, handleMessage); FcmHandler({
channel = new BroadcastChannel("imex-sw-messages"); client,
channel.addEventListener("message", handleMessage); payload: (payload && payload.data && payload.data.data) || payload.data
} catch (error) { });
console.log("Unable to set event listeners."); }
}
return () => {
stopMessageListener && stopMessageListener();
channel && channel.removeEventListener("message", handleMessage);
};
}, [client]);
if (!bodyshop || !bodyshop.messagingservicesid) return <></>; let stopMessageListener, channel;
try {
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]);
return ( if (!bodyshop || !bodyshop.messagingservicesid) return <></>;
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
{bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent/> : null} return (
</div> <div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
); {bodyshop && bodyshop.messagingservicesid ? <ChatPopupComponent /> : null}
</div>
);
} }
export default ChatAffixContainer; export default ChatAffixContainer;

View File

@@ -1,29 +1,27 @@
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.archive")}
? t("messaging.labels.unarchive") </Button>
: t("messaging.labels.archive")} );
</Button>
);
} }

View File

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

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", {
conversationId: convId,
jobId: jobId,
});
} }
}; });
logImEXEvent("messaging_remove_job_tag", {
conversationId: convId,
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,15 @@ 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}</PhoneNumberFormatter>
{conversation && conversation.phone_num} <ChatLabelComponent conversation={conversation} />
</PhoneNumberFormatter> <ChatPrintButton conversation={conversation} />
<ChatLabelComponent conversation={conversation}/> <ChatConversationTitleTags jobConversations={(conversation && conversation.job_conversations) || []} />
<ChatPrintButton conversation={conversation}/> <ChatTagRoContainer conversation={conversation || []} />
<ChatConversationTitleTags <ChatArchiveButton conversation={conversation} />
jobConversations={ </Space>
(conversation && conversation.job_conversations) || [] );
}
/>
<ChatTagRoContainer conversation={conversation || []}/>
<ChatArchiveButton conversation={conversation}/>
</Space>
);
} }

View File

@@ -6,26 +6,21 @@ import ChatSendMessage from "../chat-send-message/chat-send-message.component";
import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component.jsx"; 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, conversation, messages, handleMarkConversationAsRead }) {
subState, const [loading, error] = subState;
conversation,
messages,
handleMarkConversationAsRead,
}) {
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,87 +1,81 @@
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 {CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS,} from "../../graphql/conversations.queries"; import { CONVERSATION_SUBSCRIPTION_BY_PK, GET_CONVERSATION_DETAILS } from "../../graphql/conversations.queries";
import {MARK_MESSAGES_AS_READ_BY_CONVERSATION} from "../../graphql/messages.queries"; import { MARK_MESSAGES_AS_READ_BY_CONVERSATION } from "../../graphql/messages.queries";
import {selectSelectedConversation} from "../../redux/messaging/messaging.selectors"; 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 [markConversationRead] = useMutation(MARK_MESSAGES_AS_READ_BY_CONVERSATION, {
variables: { conversationId: selectedConversation },
refetchQueries: ["UNREAD_CONVERSATION_COUNT"],
update(cache) {
cache.modify({
id: cache.identify({
__typename: "conversations",
id: selectedConversation
}),
fields: {
messages_aggregate(cached) {
return { aggregate: { count: 0 } };
}
} }
); });
}
});
const [markingAsReadInProgress, setMarkingAsReadInProgress] = useState(false); const unreadCount =
data &&
data.messages &&
data.messages.reduce((acc, val) => {
return !val.read && !val.isoutbound ? acc + 1 : acc;
}, 0);
const [markConversationRead] = useMutation( const handleMarkConversationAsRead = async () => {
MARK_MESSAGES_AS_READ_BY_CONVERSATION, if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
{ setMarkingAsReadInProgress(true);
variables: {conversationId: selectedConversation}, await markConversationRead({});
refetchQueries: ["UNREAD_CONVERSATION_COUNT"], await axios.post("/sms/markConversationRead", {
update(cache) { conversationid: selectedConversation,
cache.modify({ imexshopid: bodyshop.imexshopid
id: cache.identify({ });
__typename: "conversations", setMarkingAsReadInProgress(false);
id: selectedConversation, }
}), };
fields: {
messages_aggregate(cached) {
return {aggregate: {count: 0}};
},
},
});
},
}
);
const unreadCount = return (
data && <ChatConversationComponent
data.messages && subState={[loading || convoLoading, error || convoError]}
data.messages.reduce((acc, val) => { conversation={convoData ? convoData.conversations_by_pk : {}}
return !val.read && !val.isoutbound ? acc + 1 : acc; messages={data ? data.messages : []}
}, 0); handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
const handleMarkConversationAsRead = async () => { );
if (unreadCount > 0 && !!selectedConversation && !markingAsReadInProgress) {
setMarkingAsReadInProgress(true);
await markConversationRead({});
await axios.post("/sms/markConversationRead", {
conversationid: selectedConversation,
imexshopid: bodyshop.imexshopid,
});
setMarkingAsReadInProgress(false);
}
};
return (
<ChatConversationComponent
subState={[loading || convoLoading, error || convoError]}
conversation={convoData ? convoData.conversations_by_pk : {}}
messages={data ? data.messages : []}
handleMarkConversationAsRead={handleMarkConversationAsRead}
/>
);
} }

View File

@@ -1,68 +1,59 @@
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}) { export default function ChatLabel({ conversation }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [value, setValue] = useState(conversation.label); const [value, setValue] = useState(conversation.label);
const {t} = useTranslation(); const { t } = useTranslation();
const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL); const [updateLabel] = useMutation(UPDATE_CONVERSATION_LABEL);
const handleSave = async () => { const handleSave = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await updateLabel({ const response = await updateLabel({
variables: {id: conversation.id, label: value}, variables: { id: conversation.id, label: value }
}); });
if (response.errors) { if (response.errors) {
notification["error"]({ notification["error"]({
message: t("messages.errors.updatinglabel", { message: t("messages.errors.updatinglabel", {
error: JSON.stringify(response.errors), error: JSON.stringify(response.errors)
}), })
}); });
} else { } else {
setEditing(false); setEditing(false);
} }
} catch (error) { } catch (error) {
notification["error"]({ notification["error"]({
message: t("messages.errors.updatinglabel", { message: t("messages.errors.updatinglabel", {
error: JSON.stringify(error), error: JSON.stringify(error)
}), })
}); });
} finally { } finally {
setLoading(false); 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,100 +1,82 @@
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 import JobDocumentsLocalGalleryExternal from "../jobs-documents-local-gallery/jobs-documents-local-gallery.external.component";
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, selectedMedia, setSelectedMedia, conversation }) {
bodyshop, const { t } = useTranslation();
selectedMedia, const [open, setOpen] = useState(false);
setSelectedMedia,
conversation,
}) {
const {t} = useTranslation();
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].jobid
conversation.job_conversations[0] && },
conversation.job_conversations[0].jobid,
},
skip: skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0
!open || });
!conversation.job_conversations ||
conversation.job_conversations.length === 0,
});
const handleVisibleChange = (change) => { const handleVisibleChange = (change) => {
setOpen(change); 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 && open && ( {bodyshop.uselocalmediaserver && open && (
<JobDocumentsLocalGalleryExternal <JobDocumentsLocalGalleryExternal
externalMediaState={[selectedMedia, setSelectedMedia]} externalMediaState={[selectedMedia, setSelectedMedia]}
jobId={ jobId={conversation.job_conversations[0] && conversation.job_conversations[0].jobid}
conversation.job_conversations[0] && />
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> : content
<div>{t("messaging.errors.noattachedjobs")}</div> }
) : ( title={t("messaging.labels.selectmedia")}
content trigger="click"
) open={open}
} onOpenChange={handleVisibleChange}
title={t("messaging.labels.selectmedia")} >
trigger="click" <Badge count={selectedMedia.filter((s) => s.isSelected).length}>
open={open} <PictureFilled style={{ margin: "0 .5rem" }} />
onOpenChange={handleVisibleChange} </Badge>
> </Popover>
<Badge count={selectedMedia.filter((s) => s.isSelected).length}> );
<PictureFilled style={{margin: "0 .5rem"}}/>
</Badge>
</Popover>
);
} }

View File

@@ -1,113 +1,106 @@
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 dayjs from "../../utils/day"; 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 {AutoSizer, CellMeasurer, CellMeasurerCache, List,} from "react-virtualized"; import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from "react-virtualized";
import {DateTimeFormatter} from "../../utils/DateFormatter"; 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 (
<div className="chat"> <CellMeasurer cache={_cache} key={key} rowIndex={index} parent={parent}>
<AutoSizer> {({ measure, registerChild }) => (
{({height, width}) => ( <div
<List ref={registerChild}
ref={virtualizedListRef} onLoad={measure}
width={width} style={style}
height={height} className={`${messages[index].isoutbound ? "mine messages" : "yours messages"}`}
rowHeight={_cache.rowHeight} >
rowRenderer={_rowRenderer} <div className="message msgmargin">
rowCount={messages.length} {MessageRender(messages[index])}
overscanRowCount={10} {StatusRender(messages[index].status)}
estimatedRowSize={150} </div>
scrollToIndex={messages.length} {messages[index].isoutbound && (
/> <div style={{ fontSize: 10 }}>
)} {i18n.t("messaging.labels.sentby", {
</AutoSizer> by: messages[index].userid,
</div> time: dayjs(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} style={{ display: "flex", justifyContent: "center" }}>
key={idx} <a href={i} target="__blank">
style={{display: "flex", justifyContent: "center"}} <img alt="Received" className="message-img" src={i} />
> </a>
<a href={i} target="__blank">
<img alt="Received" className="message-img" src={i}/>
</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

@@ -1,55 +1,49 @@
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, {PhoneItemFormatterValidation,} from "../form-items-formatted/phone-form-item.component"; import PhoneFormItem, { 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 }) => PhoneItemFormatterValidation(getFieldValue, "phoneNumber")]}
({getFieldValue}) => >
PhoneItemFormatterValidation(getFieldValue, "phoneNumber"), <PhoneFormItem />
]} </Form.Item>
> <Button type="primary" htmlType="submit">
<PhoneFormItem/> {t("messaging.actions.new")}
</Form.Item> </Button>
<Button type="primary" htmlType="submit"> </Form>
{t("messaging.actions.new")} </div>
</Button> );
</Form>
</div>
);
return ( return (
<Popover trigger="click" content={popContent}> <Popover trigger="click" content={popContent}>
<PlusCircleFilled/> <PlusCircleFilled />
</Popover> </Popover>
); );
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(ChatNewConversation);
mapStateToProps,
mapDispatchToProps
)(ChatNewConversation);

View File

@@ -1,54 +1,47 @@
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, searchingForConversation, phone, jobid, openChatByPhone }) {
bodyshop, const { t } = useTranslation();
searchingForConversation, if (!phone) return <></>;
phone,
jobid,
openChatByPhone,
}) {
const {t} = useTranslation();
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,13 +1,13 @@
import {InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined,} from "@ant-design/icons"; import { InfoCircleOutlined, MessageOutlined, ShrinkOutlined, SyncOutlined } from "@ant-design/icons";
import {useLazyQuery, useQuery} from "@apollo/client"; import { useLazyQuery, useQuery } from "@apollo/client";
import {Badge, Card, Col, Row, Space, Tag, Tooltip, Typography} from "antd"; import { Badge, Card, Col, Row, Space, Tag, Tooltip, Typography } from "antd";
import React, {useCallback, useEffect, useState} from "react"; import React, { useCallback, 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 {CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT,} from "../../graphql/conversations.queries"; import { CONVERSATION_LIST_QUERY, UNREAD_CONVERSATION_COUNT } from "../../graphql/conversations.queries";
import {toggleChatVisible} from "../../redux/messaging/messaging.actions"; import { toggleChatVisible } from "../../redux/messaging/messaging.actions";
import {selectChatVisible, selectSelectedConversation,} from "../../redux/messaging/messaging.selectors"; 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";
@@ -15,119 +15,102 @@ 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, selectedConversation, toggleChatVisible }) {
chatVisible, const { t } = useTranslation();
selectedConversation, const [pollInterval, setpollInterval] = useState(0);
toggleChatVisible,
}) {
const {t} = useTranslation();
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}] = const [getConversations, { loading, data, refetch, fetchMore }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
useLazyQuery(CONVERSATION_LIST_QUERY, { fetchPolicy: "network-only",
fetchPolicy: "network-only", nextFetchPolicy: "network-only",
nextFetchPolicy: "network-only", skip: !chatVisible,
skip: !chatVisible, ...(pollInterval > 0 ? { pollInterval } : {})
...(pollInterval > 0 ? {pollInterval} : {}), });
});
const fcmToken = sessionStorage.getItem("fcmtoken"); const fcmToken = sessionStorage.getItem("fcmtoken");
useEffect(() => { useEffect(() => {
if (fcmToken) { if (fcmToken) {
setpollInterval(0); setpollInterval(0);
} else { } else {
setpollInterval(60000); setpollInterval(60000);
}
}, [fcmToken]);
useEffect(() => {
if (chatVisible)
getConversations({
variables: {
offset: 0
} }
}, [fcmToken]); });
}, [chatVisible, getConversations]);
useEffect(() => { const loadMoreConversations = useCallback(() => {
if (chatVisible) if (data)
getConversations({ fetchMore({
variables: { variables: {
offset: 0, offset: data.conversations.length
}, }
}); });
}, [chatVisible, getConversations]); }, [data, fetchMore]);
const loadMoreConversations = useCallback(() => { const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0;
if (data)
fetchMore({
variables: {
offset: data.conversations.length,
},
});
}, [data, fetchMore]);
const unreadCount = unreadData?.messages_aggregate.aggregate.count || 0; return (
<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" }}
/>
return ( <Row gutter={[8, 8]} className="chat-popup-content">
<Badge count={unreadCount}> <Col span={8}>
<Card size="small"> {loading ? (
{chatVisible ? ( <LoadingSpinner />
<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>
) : ( ) : (
<div <ChatConversationListComponent
onClick={() => toggleChatVisible()} conversationList={data ? data.conversations : []}
style={{cursor: "pointer"}} loadMoreConversations={loadMoreConversations}
> />
<MessageOutlined className="chat-popup-info-icon"/>
<strong>{t("messaging.labels.messaging")}</strong>
</div>
)} )}
</Card> </Col>
</Badge> <Col span={16}>{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

@@ -1,38 +1,34 @@
import {PlusCircleOutlined} from "@ant-design/icons"; import { PlusCircleOutlined } from "@ant-design/icons";
import {Dropdown} 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 items = bodyshop.md_messaging_presets.map((i, idx) => ({
key: idx,
label: i.label,
onClick: () => setMessage(i.text)
}));
const items = bodyshop.md_messaging_presets.map((i, idx) => ({ return (
key: idx, <div className={className}>
label: (i.label), <Dropdown trigger={["click"]} menu={{ items }}>
onClick: () => setMessage(i.text), <PlusCircleOutlined />
})); </Dropdown>
</div>
return ( );
<div className={className}>
<Dropdown trigger={["click"]} menu={{items}}>
<PlusCircleOutlined/>
</Dropdown>
</div>
);
} }
export default connect( export default connect(mapStateToProps, mapDispatchToProps)(ChatPresetsComponent);
mapStateToProps,
mapDispatchToProps
)(ChatPresetsComponent);

View File

@@ -1,46 +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);
const generateDocument = (type) => { const generateDocument = (type) => {
setLoading(true); setLoading(true);
GenerateDocument( 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
}, },
type, type,
conversation.id conversation.id
).catch(e => { ).catch((e) => {
console.warn('Something went wrong generating a document.'); console.warn("Something went wrong generating a document.");
}); });
setLoading(false); setLoading(false);
} };
return ( return (
<Space wrap> <Space wrap>
<PrinterOutlined onClick={() => generateDocument('p')}/> <PrinterOutlined onClick={() => generateDocument("p")} />
<MailOutlined onClick={() => generateDocument('e')}/> <MailOutlined onClick={() => generateDocument("e")} />
{loading && <Spin/>} {loading && <Spin />}
</Space> </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