├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── autodeploy.yml ├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── client ├── .docker │ └── Dockerfile.dev ├── .env.example ├── .gitignore ├── babel.config.js ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── logo.png ├── src │ ├── App.vue │ ├── components │ │ ├── TheHeader.vue │ │ ├── TheSidebar.vue │ │ ├── VButton.vue │ │ ├── VCard.vue │ │ ├── VContent.vue │ │ ├── VInput.vue │ │ ├── VList.vue │ │ ├── VModal.vue │ │ ├── VModalBox.vue │ │ ├── VSelect.vue │ │ ├── VTableCreate.vue │ │ ├── VTableRead.vue │ │ ├── VTableSimple.vue │ │ ├── VVat.vue │ │ └── dashboard │ │ │ ├── History.vue │ │ │ └── HistoryModal.vue │ ├── main.js │ ├── mixins │ │ ├── commonMixin.js │ │ ├── computeDoc.js │ │ ├── documentMixin.js │ │ ├── documentUtilityMixin.js │ │ ├── modalMixin.js │ │ └── titleMixin.js │ ├── observables │ │ └── history.js │ ├── router.js │ ├── sidebarLinks.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ │ ├── api.js │ │ │ ├── common │ │ │ ├── common.actions.js │ │ │ ├── common.getters.js │ │ │ ├── common.mutations.js │ │ │ └── index.js │ │ │ ├── dashboard │ │ │ ├── dashboard.actions.js │ │ │ ├── dashboard.getters.js │ │ │ ├── dashboard.mutations.js │ │ │ └── index.js │ │ │ ├── single-document │ │ │ ├── index.js │ │ │ ├── single-document.actions.js │ │ │ ├── single-document.getters.js │ │ │ └── single-document.mutations.js │ │ │ └── user │ │ │ ├── index.js │ │ │ ├── user.actions.js │ │ │ └── user.mutations.js │ ├── styles │ │ ├── base.scss │ │ ├── common.scss │ │ ├── helpers.scss │ │ ├── mixins.scss │ │ ├── modal.scss │ │ ├── table.scss │ │ └── variables.scss │ ├── utils │ │ ├── date.js │ │ └── index.js │ └── views │ │ ├── Auth.vue │ │ ├── AuthAfter.vue │ │ ├── Dashboard.vue │ │ ├── DocumentEditOne.vue │ │ ├── DocumentHome.vue │ │ ├── Documents.vue │ │ ├── File.vue │ │ ├── Products.vue │ │ ├── Providers.vue │ │ └── Settings.vue ├── tests │ └── unit │ │ ├── .eslintrc.js │ │ ├── components │ │ ├── Providers.spec.js │ │ └── VTableRead.spec.js │ │ ├── store │ │ ├── common │ │ │ ├── common.actions.spec.js │ │ │ ├── common.getters.spec.js │ │ │ └── common.mutations.spec.js │ │ └── single-document │ │ │ ├── single-document.actions.spec.js │ │ │ └── single-document.mutations.spec.js │ │ └── utils │ │ └── date.spec.js └── vue.config.js ├── docker-compose.prod.yml ├── docker-compose.yml ├── invoice-app.postman_collection.json ├── readme.md ├── screenshots ├── dashboard.png ├── inside-doc.png └── uml.jpg ├── scripts └── clean-up.sh └── server ├── .docker ├── Dockerfile.dev └── Dockerfile.prod ├── .env.example ├── .key.example ├── app.js ├── controllers ├── Dashboard.controller.js ├── Document.controller.js ├── File.controller.js ├── User.controller.js └── index.js ├── db ├── Database.js ├── DocumentInfo.js ├── Document_ProductInfo.js ├── HistoryInfo.js ├── ProductInfo.js ├── ProviderInfo.js ├── UserInfo.js ├── VatInfo.js └── index.js ├── key.js ├── middlewares └── jwt.middleware.js ├── package-lock.json ├── package.json ├── redis └── client.js ├── routes ├── Dashboard.route.js ├── Document.route.js ├── History.route.js ├── Product.route.js ├── Provider.route.js ├── User.route.js ├── Vat.route.js └── index.js ├── services ├── Dashboard.service.js ├── Document.service.js ├── File.service.js ├── User.service.js └── index.js └── utils ├── index.js └── jwt.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/autodeploy.yml: -------------------------------------------------------------------------------- 1 | name: Auto Deploy 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | # pull_request: 7 | # branches: 8 | # - master 9 | # types: [closed] 10 | 11 | 12 | jobs: 13 | deploy: 14 | # if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true || github.event_name == 'push' }} 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | with: 20 | ref: 'master' 21 | fetch-depth: 1 22 | - uses: appleboy/ssh-action@v0.1.4 23 | with: 24 | key: ${{ secrets.SSH_PRIVATE_KEY }} 25 | username: ${{ secrets.USERNAME }} 26 | host: ${{ secrets.HOST }} 27 | script: | 28 | cd ${{ secrets.PROJECT_PATH }} 29 | 30 | git fetch origin master 31 | 32 | if [ $(git log --format="%H" HEAD -1) == $(git log --format="%H" FETCH_HEAD -1) ]; then 33 | echo "No changes from the upstream." 34 | exit 0 35 | fi 36 | 37 | git diff --pretty=%gd --stat HEAD FETCH_HEAD | grep -q client/ 38 | clientExitCode=$? 39 | 40 | git diff --pretty=%gd --stat HEAD FETCH_HEAD | grep -q server/ 41 | serverExitCode=$? 42 | 43 | # Make sure to also update the top of the `master` branch. 44 | # When updating the client/server, the local `master` branch 45 | # must be up to date. 46 | git reset --hard origin/master 47 | 48 | if [ $clientExitCode == 0 ]; then 49 | echo "Updating 'client'." 50 | cd client 51 | npm run build -- & 52 | cd .. 53 | fi 54 | 55 | if [ $serverExitCode == 0 ]; then 56 | echo "Updating 'server'." 57 | docker-compose -f docker-compose.prod.yml --env-file ./server/.env -p "$(basename $(pwd))_PROD" restart server 58 | fi 59 | 60 | wait 61 | 62 | 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .env 4 | 5 | server/public 6 | 7 | .key -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "cwd": "${workspaceFolder}/server", 15 | "runtimeExecutable": "npm", 16 | "runtimeArgs": [ 17 | "run", 18 | "dev" 19 | ] 20 | }, 21 | { 22 | "name": "Debugging `server` in container", 23 | "type": "node", 24 | "request": "attach", 25 | "address": "localhost", 26 | "localRoot": "${workspaceFolder}/server", 27 | "remoteRoot": "/app", 28 | "restart": true, 29 | "port": 9229 30 | }, 31 | { 32 | "type": "node", 33 | "request": "launch", 34 | "name": "Debug client tests", 35 | "skipFiles": [ 36 | "/**" 37 | ], 38 | "cwd": "${workspaceFolder}/client", 39 | "runtimeExecutable": "npm", 40 | "runtimeArgs": [ 41 | "run", 42 | "test:unit" 43 | ], 44 | "console": "externalTerminal" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gatej Andrei 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.docker/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm i -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VUE_APP_API_URL=NULL 2 | VUE_APP_CRONJOB_START_DATE=NULL -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue' 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest' 12 | }, 13 | transformIgnorePatterns: [ 14 | '/node_modules/' 15 | ], 16 | moduleNameMapper: { 17 | '^@/(.*)$': '/src/$1' 18 | }, 19 | snapshotSerializers: [ 20 | 'jest-serializer-vue' 21 | ], 22 | testMatch: [ 23 | '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)' 24 | ], 25 | testURL: 'http://localhost/', 26 | watchPlugins: [ 27 | 'jest-watch-typeahead/filename', 28 | 'jest-watch-typeahead/testname' 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": { 3 | "include": [ 4 | "jest" 5 | ] 6 | } 7 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "invoice-app-fe", 3 | "version": "2.2.2", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "serve:dist": "vue-cli-service serve -s dist", 9 | "lint": "vue-cli-service lint", 10 | "test:unit": "vue-cli-service test:unit" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^1.2.15", 14 | "@fortawesome/free-solid-svg-icons": "^5.7.2", 15 | "@fortawesome/vue-fontawesome": "^0.1.5", 16 | "node-sass": "^4.11.0", 17 | "sass-loader": "^7.1.0", 18 | "uuid": "^3.3.2", 19 | "vue": "^2.6.6", 20 | "vue-router": "^3.0.1", 21 | "vuex": "^3.0.1" 22 | }, 23 | "devDependencies": { 24 | "@vue/cli-plugin-babel": "^3.4.0", 25 | "@vue/cli-plugin-eslint": "^3.4.0", 26 | "@vue/cli-plugin-unit-jest": "^3.8.0", 27 | "@vue/cli-service": "^3.8.4", 28 | "@vue/test-utils": "1.0.0-beta.29", 29 | "babel-core": "7.0.0-bridge.0", 30 | "babel-eslint": "^10.0.1", 31 | "babel-jest": "^23.6.0", 32 | "eslint": "^5.8.0", 33 | "eslint-plugin-vue": "^5.0.0", 34 | "vue-template-compiler": "^2.5.21" 35 | }, 36 | "eslintConfig": { 37 | "root": true, 38 | "env": { 39 | "node": true 40 | }, 41 | "extends": [ 42 | "plugin:vue/essential", 43 | "eslint:recommended" 44 | ], 45 | "rules": { 46 | "no-console": "off" 47 | }, 48 | "parserOptions": { 49 | "parser": "babel-eslint" 50 | } 51 | }, 52 | "postcss": { 53 | "plugins": { 54 | "autoprefixer": {} 55 | } 56 | }, 57 | "browserslist": [ 58 | "> 1%", 59 | "last 2 versions", 60 | "not ie <= 8" 61 | ] 62 | } 63 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andrei0872/vue-invoice-app/a54b35feebe03f1ae4933a8ac4104b6e928455b7/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Invoice application 9 | 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andrei0872/vue-invoice-app/a54b35feebe03f1ae4933a8ac4104b6e928455b7/client/public/logo.png -------------------------------------------------------------------------------- /client/src/App.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 48 | 49 | 60 | -------------------------------------------------------------------------------- /client/src/components/TheHeader.vue: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 27 | -------------------------------------------------------------------------------- /client/src/components/TheSidebar.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 53 | 54 | -------------------------------------------------------------------------------- /client/src/components/VButton.vue: -------------------------------------------------------------------------------- 1 | 2 | 48 | 49 | -------------------------------------------------------------------------------- /client/src/components/VCard.vue: -------------------------------------------------------------------------------- 1 | 2 | 38 | 39 | -------------------------------------------------------------------------------- /client/src/components/VContent.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 86 | 87 | 114 | -------------------------------------------------------------------------------- /client/src/components/VInput.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 55 | 56 | -------------------------------------------------------------------------------- /client/src/components/VList.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 87 | 88 | -------------------------------------------------------------------------------- /client/src/components/VModal.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 120 | -------------------------------------------------------------------------------- /client/src/components/VModalBox.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 108 | -------------------------------------------------------------------------------- /client/src/components/VSelect.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 32 | -------------------------------------------------------------------------------- /client/src/components/VTableSimple.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/components/VVat.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | 22 | 36 | -------------------------------------------------------------------------------- /client/src/components/dashboard/History.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 96 | 97 | 213 | -------------------------------------------------------------------------------- /client/src/components/dashboard/HistoryModal.vue: -------------------------------------------------------------------------------- 1 | 115 | 116 | 232 | 233 | 252 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store/' 5 | 6 | import { library } from '@fortawesome/fontawesome-svg-core'; 7 | // Load Icons 8 | import { 9 | faIndustry, 10 | faCartPlus, 11 | faCog, 12 | faFile, 13 | faTachometerAlt, 14 | faTimes, 15 | faArrowRight, 16 | faArrowLeft, 17 | faPlusCircle, 18 | faMinusCircle, 19 | faPencilAlt, 20 | faCheck, 21 | faClipboardList, 22 | faPlus, 23 | faMinus, 24 | faFilePdf, 25 | faFileExcel, 26 | faSignOutAlt, 27 | } from '@fortawesome/free-solid-svg-icons' 28 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 29 | 30 | // Add icons to the library 31 | library.add( 32 | faIndustry, faCartPlus, faCog, faFile, faTachometerAlt, faTimes, faArrowRight, faPlusCircle, faMinusCircle, 33 | faPencilAlt, faCheck, faClipboardList, faArrowLeft, faPlus, faMinus, faFilePdf, faFileExcel, faSignOutAlt 34 | ); 35 | 36 | Vue.component('font-awesome-icon', FontAwesomeIcon); 37 | 38 | Vue.config.productionTip = false 39 | 40 | new Vue({ 41 | router, 42 | store, 43 | render: h => h(App) 44 | }).$mount('#app') 45 | -------------------------------------------------------------------------------- /client/src/mixins/commonMixin.js: -------------------------------------------------------------------------------- 1 | import uuidv1 from 'uuid/v1'; 2 | 3 | import { 4 | getDiffBetweenMapsElements, 5 | convertMapToArr, 6 | } from '@/utils/' 7 | 8 | export default { 9 | data: () => ({ 10 | selectedItem: {}, 11 | isCreating: false, 12 | isAboutToDelete: false, 13 | showDetails: false, 14 | disableCreateButton: false, 15 | }), 16 | 17 | methods: { 18 | 19 | createNewItem () { 20 | const newItemDetails = Object.assign({}, ... (this.createColumns.map(field => ({ [field]: '' })))); 21 | 22 | const id = uuidv1(); 23 | 24 | return { id, ...newItemDetails }; 25 | }, 26 | 27 | showInfo (row) { 28 | this.selectedItem = { ...row }; 29 | this.showDetails = true; 30 | }, 31 | 32 | addRow () { 33 | this.addCreatedItem(this.createNewItem()); 34 | 35 | if (this.disableCreateButton) { 36 | this.disableCreateButton = false; 37 | } 38 | }, 39 | 40 | prepareRowForDeletion (row) { 41 | this.isAboutToDelete = true; 42 | this.selectedItem = { ...row }; 43 | this.showDetails = true; 44 | }, 45 | 46 | deleteRowInstantly (rowId) { 47 | this.deleteCreatedItem(rowId); 48 | 49 | if (this.createdItems.length === 0) { 50 | this.disableCreateButton = true; 51 | } 52 | }, 53 | 54 | confirmDelete () { 55 | this.deleteItem(this.selectedItem.id); 56 | this.resetModalContent(); 57 | }, 58 | 59 | cancelDelete () { 60 | this.resetModalContent(); 61 | }, 62 | 63 | resetModalContent () { 64 | this.showDetails = false; 65 | this.isAboutToDelete = false; 66 | }, 67 | 68 | addField ([rowId, fieldName, value]) { 69 | this.addFieldValue({ rowId, fieldName, value }); 70 | }, 71 | 72 | onTableCreateReady() { 73 | this.resetCreatedItems(); 74 | this.addCreatedItem(this.createNewItem()); 75 | }, 76 | 77 | updateRow (data) { 78 | this.updateItem(data); 79 | }, 80 | 81 | async fetchItems (url = null, anotherEntity = null) { 82 | await this.$store.dispatch('api/makeGETRequest', { 83 | url: (url || this.backendUrl), 84 | entity: (anotherEntity || this.entity ) 85 | }); 86 | }, 87 | 88 | sendDeletedHistoryData () { 89 | let itemsLen = this.items.length; 90 | 91 | const message = `Delete ${this.entity}`; 92 | const entity = `${ itemsLen === 0 ? this.entity + '/empty' : this.entity}`; 93 | const action_type = 'delete'; 94 | const prev_state = JSON.stringify(convertMapToArr(this.deletedItems)); 95 | 96 | this.sendHistoryData({ 97 | message, 98 | entity, 99 | action_type, 100 | prev_state 101 | }); 102 | }, 103 | 104 | 105 | sendUpdatedHistoryData () { 106 | const entity = this.entity; 107 | const action_type = 'update'; 108 | const message = `Update ${this.entity}`; 109 | 110 | const differences = getDiffBetweenMapsElements( 111 | this.itemsMap, 112 | this.updatedItemsMap 113 | ); 114 | 115 | const current_state = JSON.stringify(differences); 116 | 117 | console.log('differences', differences) 118 | 119 | this.sendHistoryData({ 120 | message, 121 | entity, 122 | action_type, 123 | current_state, 124 | }); 125 | }, 126 | 127 | sendCreatedHistoryData (createdItemsInCustomFormat = null) { 128 | const message = `Add new ${this.entity}s`; 129 | 130 | this.$store.dispatch('dashboard/insertHistoryRow', { 131 | entity: this.entity, 132 | message, 133 | action_type: 'insert', 134 | ...createdItemsInCustomFormat 135 | && createdItemsInCustomFormat 136 | || { current_state: JSON.stringify(this.createdItemsAsArrWithoutIds) }, 137 | }); 138 | }, 139 | 140 | openModalBox (title) { 141 | this.$root.$emit('activateModalBox', title); 142 | }, 143 | 144 | refetchMainOverview () { 145 | this.$store.dispatch('dashboard/fetchMainOverview', 'dashboard/overview', { root: true }); 146 | }, 147 | }, 148 | 149 | computed: { 150 | modalTitle () { 151 | // If we are editing a document(this.$route.name !== 'documentEditOne') 152 | // make sure to display the name of the product 153 | return this.isAboutToDelete 154 | ? `Are you sure you want to delete ${ 155 | this.$store.state.currentEntity === 'documents' && this.$route.name !== 'documentEditOne' 156 | ? 'this document' 157 | : this.selectedItem.name || (this.$route.name === 'documentEditOne' && this.selectedItem.product_name) 158 | } ?` 159 | : `About ${this.selectedItem.name}` 160 | }, 161 | 162 | everythingReady() { 163 | return this.$store.state['everythingReady'] 164 | }, 165 | 166 | backendUrl () { 167 | return this.$store.getters['getEntityBackendEndpoint']; 168 | }, 169 | 170 | mainUrl () { 171 | return this.$store.state['mainUrl']; 172 | }, 173 | }, 174 | } -------------------------------------------------------------------------------- /client/src/mixins/computeDoc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | /** 4 | * Compute a new value for `sell_price` depending on which values can be found 5 | * in `buy_price` / `markup` fields 6 | */ 7 | computeSellPrice (row, fieldName, currentFieldValue) { 8 | currentFieldValue = parseFloat(currentFieldValue); 9 | currentFieldValue = isNaN(currentFieldValue) ? 0 : currentFieldValue; 10 | 11 | const otherField = fieldName === 'buy_price' ? 'markup' : 'buy_price'; 12 | let existingValueInOtherField = parseFloat(row[otherField]); 13 | existingValueInOtherField = isNaN(existingValueInOtherField) ? 0 : existingValueInOtherField; 14 | 15 | const sellPriceValue = this.getValueAfterMarkup(...fieldName === 'buy_price' ? ([currentFieldValue, existingValueInOtherField]) : ([existingValueInOtherField, currentFieldValue])) 16 | 17 | return +sellPriceValue.toFixed(2) 18 | }, 19 | 20 | getValueAfterMarkup (buyPrice, markup) { 21 | return buyPrice + markup; 22 | }, 23 | 24 | getVatValue (isComestible, sellPriceValue, vat) { 25 | return ((parseFloat(isComestible === 1 ? vat['food_vat'] : vat['non_food_vat'])) / 100) * sellPriceValue 26 | }, 27 | } 28 | } -------------------------------------------------------------------------------- /client/src/mixins/documentMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | createColumns: [ 5 | "product_name", 6 | "quantity", 7 | "quantity_type", 8 | "buy_price", 9 | "markup", 10 | "sell_price", 11 | "product_vat", 12 | "sell_price_vat", 13 | "currency" 14 | ], 15 | readColumns: [ 16 | "provider_name", 17 | "total_buy", 18 | "total_sell", 19 | "total_vat", 20 | "total_sell_vat", 21 | "invoice_number", 22 | "inserted_date", 23 | "nr_products" 24 | ], 25 | 26 | initialListItemsLen: 0, 27 | unwatchStore: null, 28 | isDeletingCreatedItems: false, 29 | } 30 | }, 31 | 32 | methods: { 33 | initCreatedItemsWatcher () { 34 | 35 | this.unwatchStore = this.$store.watch( 36 | (state, getters) => getters[this.createdItemsObservee], 37 | (createdItems) => { 38 | if (this.isDeletingCreatedItems) 39 | return; 40 | 41 | if (createdItems.length > this.initialListItemsLen && !this.productsAsList.length) { 42 | console.log('should delete'); 43 | 44 | this.deleteExcessiveCreatedItems(); 45 | } 46 | } 47 | ) 48 | }, 49 | 50 | deleteExcessiveCreatedItems () { 51 | this.isDeletingCreatedItems = true; 52 | 53 | (this.createdItems || this.createdProducts).forEach(({ id: deletedId, product_name }) => { 54 | if (product_name === '' || typeof product_name !== 'object') 55 | (this.deleteCreatedItem || this.deleteCreatedProduct)(deletedId); 56 | }) 57 | 58 | this.isDeletingCreatedItems = false; 59 | } 60 | }, 61 | 62 | destroyed () { 63 | this.unwatchStore(); 64 | }, 65 | } -------------------------------------------------------------------------------- /client/src/mixins/documentUtilityMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | refetchDocuments () { 4 | if (!this.$store.state['document']) 5 | return; 6 | 7 | const endpoint = 'documents'; 8 | const entity = 'document'; 9 | const url = this.$store.state['mainUrl'] + endpoint; 10 | 11 | this.$store.dispatch('api/makeGETRequest', { url, entity }); 12 | }, 13 | } 14 | } -------------------------------------------------------------------------------- /client/src/mixins/modalMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data: () => ({ 3 | showDetails: false 4 | }), 5 | 6 | watch: { 7 | showDetails (shouldDisplayModal) { 8 | shouldDisplayModal 9 | ? 10 | window.addEventListener("keyup", this.modalHandler) : 11 | window.removeEventListener("keyup", this.modalHandler) 12 | } 13 | }, 14 | 15 | methods: { 16 | modalHandler (e) { 17 | e.which === 27 && this.closeModal(); 18 | }, 19 | 20 | closeModal () { 21 | this.showDetails = false; 22 | this.selectedItem = {}; 23 | }, 24 | }, 25 | } -------------------------------------------------------------------------------- /client/src/mixins/titleMixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | beforeMount() { 3 | const { title = null } = this.$options; 4 | 5 | title && (document.title = title); 6 | }, 7 | } -------------------------------------------------------------------------------- /client/src/observables/history.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | export default Vue.observable({ 4 | // Used in HistoryModal.vue 5 | showModal: false, 6 | selectedHistoryRow: null, 7 | }) -------------------------------------------------------------------------------- /client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import store from './store/'; 4 | 5 | import { capitalize } from './utils'; 6 | 7 | const loadComp = (path = '/', children = null, name = path.slice(1), component = capitalize(name)) => { 8 | return { 9 | path, 10 | component: () => import(`./views/${component}`), 11 | ...{ hasChildren: children !== null }.hasChildren && { 12 | children 13 | } || { name } 14 | } 15 | } 16 | 17 | 18 | Vue.use(Router) 19 | 20 | const router = new Router({ 21 | mode: 'history', 22 | linkActiveClass: 'active', 23 | routes: [ 24 | // After auth 25 | loadComp('/', [ 26 | loadComp('/', null, 'dashboard'), 27 | loadComp('/settings'), 28 | loadComp('/products'), 29 | loadComp('/providers'), 30 | loadComp('/documents', [ 31 | loadComp('/documents', null, 'documents'), 32 | { ...loadComp('/documents/edit/:id(\\d+)', null, 'documentEditOne') } 33 | ], undefined, 'DocumentHome'), 34 | ], undefined, 'AuthAfter'), 35 | 36 | // Show PDF 37 | loadComp('/pdf/:id(\\d+)', null, 'file', 'File'), 38 | loadComp('/auth', null, 'auth'), 39 | { 40 | path: '**', 41 | beforeEnter: (to, from, next) => { 42 | if (!store.state.user.currentUser) { 43 | return next('/auth'); 44 | } 45 | 46 | return next('/'); 47 | } 48 | } 49 | ] 50 | }) 51 | 52 | const entities = ["documents", "products", "providers"]; 53 | router.beforeEach((to, from, next) => { 54 | if (to.name !== 'auth' && !store.state.user.currentUser) { 55 | return next('/auth'); 56 | } 57 | 58 | entities.includes(to.name) && store.dispatch('changeEntity', to.name) 59 | 60 | next(); 61 | }); 62 | 63 | export default router; -------------------------------------------------------------------------------- /client/src/sidebarLinks.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { id: 1, title: 'Dashboard', route: '/', icon: 'tachometer-alt' }, 3 | { id: 2, title: 'Products', route: '/products', icon: 'cart-plus' }, 4 | { id: 3, title: 'Providers', route: '/providers', icon: 'industry' }, 5 | { id: 4, title: 'Documents', route: '/documents', icon: 'file' }, 6 | // { id: 5, title: 'Settings', route: '/settings', icon: 'cog' }, 7 | ] -------------------------------------------------------------------------------- /client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import router from '@/router'; 4 | 5 | import * as api from './modules/api'; 6 | import * as singleDocument from './modules/single-document/'; 7 | import * as dashboard from './modules/dashboard/'; 8 | import * as user from './modules/user/'; 9 | 10 | Vue.use(Vuex) 11 | 12 | const store = new Vuex.Store({ 13 | strict: process.env.NODE_ENV !== 'production', 14 | 15 | state: { 16 | everythingReady: null, 17 | currentEntity: null, 18 | selectedProvider: null, 19 | // FIXME: use the URL from api/getters 20 | mainUrl: `${process.env.VUE_APP_API_URL}/api/`, 21 | 22 | endSessionTimeoutId: null, 23 | }, 24 | 25 | getters: { 26 | getEntityName: state => state.currentEntity.slice(0, -1), 27 | 28 | getEntityNameSingularForm: state => state.currentEntity.slice(0, -1), 29 | 30 | getEntityNamePluralForm: state => state.currentEntity, 31 | 32 | getEntityBackendEndpoint: state => state.mainUrl + state.currentEntity, 33 | 34 | getEntityNewItems: state => { 35 | const entityName = state.currentEntity.slice(0, -1) 36 | 37 | if (state[entityName]) 38 | return state[entityName].newItems 39 | }, 40 | 41 | getEntityItems: (state, getters, rootState, rootGetters) => { 42 | return rootGetters[`${getters.getEntityName}/getItemsAsArr`]; 43 | }, 44 | 45 | hasDocumentRegistered: (state, getters, rootState) => !!rootState['document'] 46 | }, 47 | 48 | mutations: { 49 | CHANGE_STATE: (state, payload) => state.everythingReady = payload, 50 | 51 | ADD_ITEM: (_, { state, prop, payload }) => state[prop].push(payload), 52 | 53 | CHANGE_ENTITY: (state, payload) => state.currentEntity = payload, 54 | 55 | SET_PROVIDER: (state, payload) => state.selectedProvider = payload, 56 | 57 | SET_PROVIDER_INVOICE_NR: (state, payload) => state.selectedProvider = { ...state.selectedProvider, invoiceNr: payload }, 58 | 59 | SET_END_SESSION_TIMEOUT_ID: (state, payload) => state.endSessionTimeoutId = payload, 60 | }, 61 | 62 | actions: { 63 | changeEntity: ({ commit }, payload) => commit('CHANGE_ENTITY', payload), 64 | }, 65 | 66 | modules: { 67 | api, 68 | singleDocument, 69 | dashboard, 70 | user, 71 | } 72 | }); 73 | 74 | store.subscribeAction(action => { 75 | if (action.type === 'user/logout') { 76 | router.push('/auth'); 77 | } 78 | }) 79 | 80 | export default store; -------------------------------------------------------------------------------- /client/src/store/modules/api.js: -------------------------------------------------------------------------------- 1 | export const namespaced = true; 2 | 3 | const API_URL = process.env.VUE_APP_API_URL; 4 | 5 | let isExchanging = false; 6 | const requestsToRemake = []; 7 | 8 | export const getters = { 9 | config: (state, getters, rootState) => ({ 10 | headers: new Headers({ 11 | 'Content-Type': 'application/json', 12 | 'x-access-token': rootState.user.currentUser ? rootState.user.currentUser.token : '', 13 | }), 14 | method: "POST" 15 | }), 16 | 17 | accessToken: (state, getters, rootState) => rootState.user.currentUser ? rootState.user.currentUser.token : '', 18 | 19 | mainURL: () => `${API_URL}/api`, 20 | updateEndpoint: () => '/update', 21 | deleteEndpoint: () => '/delete' 22 | } 23 | 24 | export const actions = { 25 | FETCH_DATA: async ({ getters, rootState, dispatch, commit }, { avoidChangingState = false, anotherEntity = null } = {}) => { 26 | const moduleName = rootState.currentEntity.slice(0, -1); 27 | 28 | const url = `${getters.mainURL}/${!(anotherEntity) ? rootState.currentEntity : anotherEntity}`; 29 | 30 | try { 31 | !(avoidChangingState) && commit('CHANGE_STATE', 'pending', { root: true }); 32 | 33 | const { data } = await dispatch('makeRequest', { url, config: { ...getters.config, method: "GET" } }) 34 | 35 | console.log(data) 36 | dispatch(`${!(anotherEntity) ? moduleName : anotherEntity.slice(0, -1)}/setItems`, data, { root: true }); 37 | !(avoidChangingState) && commit('CHANGE_STATE', true, { root: true }); 38 | } catch { 39 | commit('CHANGE_STATE', null, { root: true }); 40 | } 41 | }, 42 | 43 | makeGETRequest: async ({ dispatch, getters }, { url, entity = null }) => { 44 | try { 45 | // !(avoidChangingState) && commit('CHANGE_STATE', 'pending', { root: true }); 46 | 47 | const config = { 48 | ...getters.config, 49 | method: "GET", 50 | }; 51 | 52 | const { data } = await dispatch('makeRequest', { url, config }); 53 | 54 | if (entity) 55 | return dispatch(`${entity}/setItems`, data, { root: true }); 56 | 57 | return data; 58 | } catch (err) { 59 | console.error(err); 60 | // commit('CHANGE_STATE', null, { root: true }); 61 | } 62 | }, 63 | 64 | makePOSTRequest: async ({ getters, rootState, dispatch }, { url, payload } ) => { 65 | if (rootState.selectedProvider) { 66 | payload = { 67 | items: payload, 68 | provider: rootState.selectedProvider 69 | }; 70 | } 71 | 72 | const config = { 73 | ...getters.config, 74 | body: JSON.stringify(payload) 75 | }; 76 | 77 | try { 78 | return await dispatch('makeRequest', { url, config }); 79 | } catch (err) { 80 | console.error(err) 81 | return { err }; 82 | } 83 | }, 84 | 85 | makePUTRequest: async ({ getters, dispatch }, { url, payload }) => { 86 | const config = { 87 | body: JSON.stringify(payload), 88 | ...getters.config, 89 | method: "PUT", 90 | }; 91 | 92 | return await dispatch('makeRequest', { url, config }); 93 | }, 94 | 95 | makeDELETERequest: async ({ getters, dispatch, commit }, { url, payload }) => { 96 | const config = { 97 | body: JSON.stringify(payload), 98 | ...getters.config, 99 | method: "DELETE" 100 | }; 101 | 102 | return await dispatch('makeRequest', { url, config }); 103 | }, 104 | 105 | makeRequest: ({ getters, rootState, dispatch, commit }, { url, config }) => { 106 | return new Promise(async (resolve, reject) => { 107 | try { 108 | const initialResponse = await fetch(url, config); 109 | 110 | if (!initialResponse.ok) 111 | throw initialResponse; 112 | 113 | resolve((await initialResponse.json())) 114 | } catch (err) { 115 | const isExchangingRequest = url.includes('token'); 116 | if (!isExchangingRequest && err.status === 401) { 117 | // The current request has failed with a `401` error, so we want to batch 118 | // these requests until we get a new access token. 119 | requestsToRemake.push({ 120 | resolve, 121 | reject, 122 | url, 123 | config, 124 | }); 125 | } 126 | 127 | // Because the request that exchanges the refresh token for a new access token makes 128 | // use of `makeRequest` too, we want to make sure that the `/token` call is made only once. 129 | // Scenario: in the dashboard view, there are 4 requests that must be made. Previously to this change, 130 | // the request to `/token` would be made 4 times, assuming the access token has expired. 131 | const needsNewAccessToken = err.status === 401 && !isExchangingRequest && !isExchanging; 132 | if (needsNewAccessToken) { 133 | isExchanging = true; 134 | 135 | const url = `${getters.mainURL}/token`; 136 | const { refreshToken, id } = rootState.user.currentUser || {}; 137 | const config = { 138 | method: 'POST', 139 | headers: new Headers({ 140 | 'x-refresh-token': refreshToken, 141 | 'content-type': 'application/json' 142 | }), 143 | body: JSON.stringify({ id }), 144 | }; 145 | 146 | try { 147 | const updatedUserInfo = await dispatch('makeRequest', { url, config }); 148 | 149 | // Save the new changes in LS. 150 | commit('user/SET_USER', updatedUserInfo, { root: true }); 151 | isExchanging = false; 152 | 153 | // Now that we have a new access token, we remake the batched requests. 154 | await Promise.all( 155 | requestsToRemake.map( 156 | ({ url, config, ...r }) => { 157 | config.headers.set('x-access-token', getters.accessToken); 158 | return dispatch('makeRequest', { url, config }).then(r.resolve).catch(r.reject); 159 | } 160 | ) 161 | ); 162 | requestsToRemake.length = 0; 163 | } catch { 164 | // If this point is reached, it means we have an error after making a call 165 | // to `/token`, in order to receive a new access token. In this situation, 166 | // we'd simply want to log out. 167 | dispatch('user/logout', undefined, { root: true }); 168 | } 169 | } else if (isExchangingRequest) { 170 | // For `/token` requests, we want to actually reject, so that we know it's time 171 | // to log out. For the other requests, we simply store the `resolve` and `reject` 172 | // callback functions in the array responsible for batching the requests. 173 | reject(err); 174 | } 175 | } 176 | }); 177 | }, 178 | } -------------------------------------------------------------------------------- /client/src/store/modules/common/common.actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | convertMapToObjForAPI, 3 | getObjAfterDeletingCommonValues, 4 | } from '@/utils/'; 5 | 6 | export const actions = { 7 | 8 | addCreatedItem: ({ commit }, payload) => { 9 | commit('ADD_CREATED_ITEM', payload); 10 | commit('TRACK_CREATED_ITEMS'); 11 | }, 12 | 13 | resetCreatedItems: ({ commit }) => { 14 | commit('RESET_CREATED_ITEMS'); 15 | commit('TRACK_CREATED_ITEMS'); 16 | }, 17 | 18 | deleteCreatedItem: ({ commit }, rowId) => { 19 | commit('DELETE_CREATED_ITEM', rowId); 20 | commit('TRACK_CREATED_ITEMS'); 21 | }, 22 | 23 | /** 24 | * TODO: improvement 25 | * receive the new inserted row ids from the BE, 26 | * and append them to the existing items 27 | * 28 | * Current behavior: 29 | * after items are inserted, **another** request is made to fetch again 30 | * all the existing items; 31 | */ 32 | insertCreatedItems: async ({ dispatch, getters, rootGetters }) => { 33 | const createdItemsAsArr = getters.getCreatedItemsAsArr; 34 | const url = rootGetters['getEntityBackendEndpoint']; 35 | 36 | return await dispatch('api/makePOSTRequest', { payload: createdItemsAsArr, url }, { root: true }); 37 | }, 38 | 39 | // TODO: add test 40 | addFieldValue: ({ commit, state }, { rowId, fieldName, value }) => { 41 | const newCurrentItem = state.createdItems.get(rowId) || {}; 42 | const modifiedItem = { ...newCurrentItem, [fieldName]: value }; 43 | 44 | commit('ADD_CREATED_ITEM', { id: rowId, ...modifiedItem }); 45 | commit('TRACK_CREATED_ITEMS'); 46 | }, 47 | 48 | updateItem: ({ state, commit }, { id, ...updatedItemDetails }) => { 49 | const currentUpdatedItem = state.updatedItems.get(id) || {}; 50 | const pristineItem = state.items.get(id); 51 | 52 | const newUpdatedItem = { ...currentUpdatedItem, ...updatedItemDetails }; 53 | 54 | const actuallyUpdatedItem = getObjAfterDeletingCommonValues( 55 | newUpdatedItem, 56 | pristineItem, 57 | Object.keys(newUpdatedItem) 58 | ); 59 | 60 | if (actuallyUpdatedItem === null) { 61 | commit('DELETE_UPDATED_ITEM', id); 62 | } else { 63 | commit('ADD_UPDATED_ITEM', { id, ...newUpdatedItem }); 64 | } 65 | 66 | commit('TRACK_UPDATED_ITEMS'); 67 | }, 68 | 69 | sendUpdatedItems: async ({ commit, dispatch, state, rootGetters }) => { 70 | console.log('updating items'); 71 | const url = rootGetters['getEntityBackendEndpoint']; 72 | const payload = convertMapToObjForAPI(state.updatedItems); 73 | 74 | return await dispatch('api/makePUTRequest', { 75 | url, payload 76 | }, { root: true }); 77 | }, 78 | 79 | resetUpdatedItems: ({ commit }) => { 80 | commit('RESET_UPDATED_ITEMS'); 81 | commit('TRACK_UPDATED_ITEMS'); 82 | }, 83 | 84 | // Deleted 85 | deleteItem: ({ state, commit }, id) => { 86 | const deletedItem = state.items.get(id); 87 | 88 | commit('ADD_DELETED_ITEM', { id, ...deletedItem }); 89 | commit('TRACK_DELETED_ITEMS'); 90 | commit('TRACK_ITEMS'); 91 | }, 92 | 93 | sendDeletedItems: async ({ dispatch, rootGetters, state, rootState }) => { 94 | console.log('deleting items!'); 95 | const url = rootGetters['getEntityBackendEndpoint']; 96 | const payload = { deletedItemsIds: [...state.deletedItems.keys()] }; 97 | 98 | const response = await dispatch('api/makeDELETERequest', { 99 | url, payload 100 | }, { root: true }); 101 | 102 | console.log('response: DELETE', response) 103 | 104 | return response; 105 | }, 106 | 107 | resetDeletedItems: ({ commit }) => { 108 | commit('RESET_DELETED_ITEMS'); 109 | commit('TRACK_DELETED_ITEMS'); 110 | }, 111 | 112 | setItems: ({ commit, dispatch }, payload) => { 113 | dispatch('resetItems'); 114 | 115 | payload.forEach(({ id, ...item }) => commit('ADD_ITEM', { id, ...item })) 116 | commit('TRACK_ITEMS'); 117 | }, 118 | 119 | sendModifications: async ({ dispatch, state }) => { 120 | const actionsSend = []; 121 | 122 | if (state.updatedItems.size) { 123 | actionsSend.push(dispatch('sendUpdatedItems')); 124 | } 125 | 126 | if (state.deletedItems.size) { 127 | actionsSend.push(dispatch('sendDeletedItems')); 128 | } 129 | 130 | return await Promise.all(actionsSend); 131 | }, 132 | 133 | // Created Updated Deleted 134 | resetCUDItems: ({ dispatch }) => { 135 | dispatch('resetDeletedItems'); 136 | dispatch('resetUpdatedItems'); 137 | dispatch('resetCreatedItems'); 138 | }, 139 | 140 | resetItems: ({ commit }) => { 141 | commit('RESET_ITEMS'); 142 | commit('TRACK_ITEMS'); 143 | }, 144 | 145 | sendHistoryData: async ({ dispatch }, historyData) => { 146 | dispatch('dashboard/insertHistoryRow', historyData, { root: true }); 147 | }, 148 | 149 | resetChanges: ({ commit }) => { 150 | commit('TRACK_ITEMS'); 151 | }, 152 | } -------------------------------------------------------------------------------- /client/src/store/modules/common/common.getters.js: -------------------------------------------------------------------------------- 1 | import { convertMapToArr } from '@/utils/'; 2 | 3 | export const getters = { 4 | getItemsAsArr: state => 5 | state.itemsTracker 6 | && convertMapToArr( 7 | state.items, 8 | 'id', 9 | state.deletedItems.size ? state.deletedItems : null, 10 | state.updatedItems 11 | ), 12 | 13 | getCreatedItemsAsArr: state => state.createdItemsTracker && convertMapToArr(state.createdItems, 'id'), 14 | 15 | getUpdatedItemsAsArr: state => state.updatedItemsTracker && convertMapToArr(state.updatedItems, 'id'), 16 | 17 | getWhetherItShouldEnableConfirmBtn: state => 18 | state.updatedItemsTracker && state.updatedItems.size !== 0 19 | || state.deletedItemsTracker && state.deletedItems.size !== 0, 20 | 21 | getDeletedItems: state => state.deletedItemsTracker && state.deletedItems, 22 | 23 | getUpdatedItems: state => state.updatedItemsTracker && state.updatedItems, 24 | 25 | getItems: state => state.itemsTracker && state.items, 26 | 27 | getCreatedItemsAsArrWithoutIds: state => state.createdItemsTracker && convertMapToArr(state.createdItems), 28 | }; -------------------------------------------------------------------------------- /client/src/store/modules/common/common.mutations.js: -------------------------------------------------------------------------------- 1 | export const mutations = { 2 | SET_ITEMS: (state, payload) => state.items = payload, 3 | 4 | ADD_ITEM: (state, { id, ...itemDetails }) => state.items.set(id, itemDetails), 5 | 6 | ADD_CREATED_ITEM: (state, { id, ...newItemDetails }) => state.createdItems.set(id, newItemDetails), 7 | 8 | ADD_UPDATED_ITEM: (state, { id, ...newUpdatedItemDetails }) => state.updatedItems.set(id, newUpdatedItemDetails), 9 | 10 | ADD_DELETED_ITEM: (state, { id, ...deletedItemDetails }) => state.deletedItems.set(id, deletedItemDetails), 11 | 12 | DELETE_ITEM: (state, id) => { 13 | state.items.delete(id); 14 | }, 15 | 16 | UPDATE_NEW_DATA: (state, payload) => state.newItems = payload, 17 | 18 | // DELETE_ITEM: (state, { 19 | // prop, 20 | // id 21 | // }) => state[prop] = state[prop].filter(item => item.id !== id), 22 | 23 | RESET_ARR: (state, { 24 | prop 25 | }) => state[prop] = [], 26 | 27 | RESET_CREATED_ITEMS: state => state.createdItems.clear(), 28 | 29 | DELETE_CREATED_ITEM: (state, rowId) => state.createdItems.delete(rowId), 30 | 31 | RESET_DELETED_ITEMS: state => state.deletedItems.clear(), 32 | 33 | TRACK_CREATED_ITEMS: state => state.createdItemsTracker++, 34 | 35 | TRACK_ITEMS: state => state.itemsTracker++, 36 | 37 | RESET_ITEMS: state => state.items.clear(), 38 | 39 | TRACK_UPDATED_ITEMS: state => state.updatedItemsTracker++, 40 | 41 | RESET_UPDATED_ITEMS: state => state.updatedItems.clear(), 42 | 43 | TRACK_DELETED_ITEMS: state => state.deletedItemsTracker++, 44 | 45 | DELETE_UPDATED_ITEM: (state, id) => state.updatedItems.delete(id), 46 | } -------------------------------------------------------------------------------- /client/src/store/modules/common/index.js: -------------------------------------------------------------------------------- 1 | export const namespaced = true; 2 | 3 | export const state = () => ({ 4 | items: new Map(), 5 | createdItems: new Map(), 6 | updatedItems: new Map(), 7 | deletedItems: new Map(), 8 | itemsTracker: 1, 9 | createdItemsTracker: 1, 10 | updatedItemsTracker: 1, 11 | deletedItemsTracker: 1, 12 | }) 13 | 14 | export { getters } from './common.getters'; 15 | 16 | export { mutations } from './common.mutations'; 17 | 18 | export { actions } from './common.actions'; -------------------------------------------------------------------------------- /client/src/store/modules/dashboard/dashboard.actions.js: -------------------------------------------------------------------------------- 1 | export const actions = { 2 | fetchMainOverview: async ({ dispatch, commit, getters, rootGetters }, endpoint = null) => { 3 | const mainUrl = rootGetters['api/mainURL']; 4 | const reqConfig = { ...rootGetters['api/config'], method: "GET"}; 5 | const endpoints = getters.getEndpoints; 6 | 7 | if (endpoint === null) { 8 | (await Promise.all( 9 | endpoints.map( 10 | endpoint => dispatch('api/makeRequest', { config: reqConfig, url: `${mainUrl}/${endpoint}` }, { root: true }) 11 | ) 12 | )).forEach(({ data }, index) => commit('SET_PROP_DATA', { stateProp: endpoints[index], payload: index !== 1 && data || data[0] })); 13 | 14 | commit('SET_INIT_FALSE'); 15 | } else { 16 | const { data } = await dispatch('api/makeRequest', { config: reqConfig, url: `${mainUrl}/${endpoint}` }, { root: true }); 17 | commit('SET_PROP_DATA', { stateProp: endpoint, payload: data }); 18 | commit('SET_UPDATE_STATE', !getters.getUpdateState) 19 | } 20 | 21 | }, 22 | 23 | setNewVat: ({ commit, state }, { type, value }) => { 24 | const copyVat = { ...state.vat }; 25 | copyVat[type] = value; 26 | 27 | console.log(copyVat) 28 | commit('SET_NEW_VAT', copyVat); 29 | }, 30 | 31 | // When any VAT values has changed, recompute the columns(i.e buy_price, sell_price) 32 | // depending on the new values that have been provided 33 | updateDocVat: async ({ dispatch, rootGetters }, payload) => { 34 | 35 | const url = `${rootGetters['api/mainURL']}/documents/update_document_vat` 36 | const config = { 37 | ...rootGetters['api/config'], 38 | method: "PUT", 39 | body: JSON.stringify(payload) 40 | } 41 | 42 | return await dispatch("api/makeRequest", { url, config }, { root: true }); 43 | }, 44 | 45 | insertHistoryRow: async ({ dispatch, rootGetters }, payload) => { 46 | const url = `${rootGetters['api/mainURL']}/history/insert`; 47 | const config = { 48 | ...rootGetters['api/config'], 49 | body: JSON.stringify(payload) 50 | } 51 | 52 | await dispatch('api/makeRequest', { url, config }, { root: true }) 53 | 54 | await dispatch('dashboard/fetchMainOverview', 'history', { root: true }); 55 | }, 56 | 57 | updateVat: async ({ dispatch, rootGetters }, payload) => { 58 | const url = `${rootGetters['api/mainURL']}/vat/`; 59 | 60 | await dispatch('api/makePUTRequest', { url, payload }, { root: true }); 61 | }, 62 | } -------------------------------------------------------------------------------- /client/src/store/modules/dashboard/dashboard.getters.js: -------------------------------------------------------------------------------- 1 | export const getters = { 2 | getEndpoints: () => ['dashboard/overview', 'vat', 'history'], 3 | 4 | getUpdateState: state => state.needsUpdate, 5 | 6 | getCurrentVat: state => state.vat, 7 | 8 | getHistoryLen: state => state.history.length, 9 | 10 | needsInit: state => !state.isInit, 11 | 12 | getDocumentsLen: (state, getters, rootState) => rootState['document'] ? rootState['document'].items.length : null 13 | } -------------------------------------------------------------------------------- /client/src/store/modules/dashboard/dashboard.mutations.js: -------------------------------------------------------------------------------- 1 | export const mutations = { 2 | SET_PROP_DATA: (state, { stateProp, payload }) => state[stateProp] = payload, 3 | 4 | SET_NEW_VAT: (state, payload) => state.vat = payload, 5 | 6 | SET_UPDATE_STATE: (state, payload) => state.needsUpdate = payload, 7 | 8 | SET_INIT_FALSE: state => state.isInit = true, 9 | 10 | ADD_DOCUMENT_IDS: (state, payload) => state.documentIds = new Map(payload) 11 | } 12 | -------------------------------------------------------------------------------- /client/src/store/modules/dashboard/index.js: -------------------------------------------------------------------------------- 1 | export const namespaced = true; 2 | 3 | export const state = { 4 | ['dashboard/overview']: {}, 5 | vat: {}, 6 | history: [], 7 | needsUpdate: false, 8 | isInit: false, 9 | // Used to identify which documents no longer exist 10 | documentIds: null, 11 | } 12 | 13 | export { getters } from './dashboard.getters'; 14 | 15 | export { mutations } from './dashboard.mutations'; 16 | 17 | export { actions } from './dashboard.actions'; -------------------------------------------------------------------------------- /client/src/store/modules/single-document/index.js: -------------------------------------------------------------------------------- 1 | export const namespaced = true; 2 | 3 | export const state = { 4 | products: new Map(), 5 | updatedProducts: new Map(), 6 | createdProducts: new Map(), 7 | deletedProducts: new Map(), 8 | 9 | /** 10 | * Looks for changes regarding the provider_id and the invoice number 11 | * 12 | * The other values(price etc..) are generated when the document is fetched 13 | */ 14 | currentDocumentNewData: null, 15 | // Used to verify if changes have been made to this document 16 | currentDocumentOwnPristineData: null, 17 | 18 | // ? needs a deeper look 19 | // deletedItems: new Map(), 20 | // changes: {}, 21 | pristineData: new Map, 22 | currentId: null, 23 | alreadyFetched: false, 24 | lastDeletedDocId: -1, 25 | // ? =================== 26 | 27 | productsTracker: 1, 28 | updatedProductsTracker: 1, 29 | createdProductsTracker: 1, 30 | deletedProductsTracker: 1, 31 | } 32 | 33 | export { getters } from './single-document.getters'; 34 | 35 | export { mutations } from './single-document.mutations'; 36 | 37 | export { actions } from './single-document.actions'; -------------------------------------------------------------------------------- /client/src/store/modules/single-document/single-document.actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | convertMapToObject, 3 | convertMapToArrExcludingProps, 4 | getDiffBetweenMapsElements, 5 | getObjAfterDeletingCommonValues, 6 | } from '@/utils/'; 7 | 8 | export const actions = { 9 | 10 | // Document 11 | fetchProductsByDocumentId: async ({ commit, dispatch, rootGetters, rootState }, id) => { 12 | const url = `${rootGetters['api/mainURL']}/documents/${id}`; 13 | const products = rootState.product.items; 14 | 15 | const documentProductsRaw = await dispatch('api/makeGETRequest', { url }, { root: true }); 16 | 17 | const documentProducts = documentProductsRaw.map( 18 | (product) => ({ 19 | ...product, 20 | product_name: products.get(product.product_id).name, 21 | /** 22 | * This will be used when computing values that depend on 23 | * `buy_price` and `markup`, such as `sell_price`, `product_vat` 24 | * and `sell_price_vat` 25 | */ 26 | isComestible: products.get(product.product_id).comestible 27 | }) 28 | ); 29 | 30 | dispatch('setProducts', documentProducts); 31 | }, 32 | 33 | setProducts: ({ commit }, products) => { 34 | products.forEach(p => commit('ADD_PRODUCT', p)); 35 | 36 | commit('TRACK_PRODUCTS'); 37 | }, 38 | 39 | resetProducts: ({ commit }) => { 40 | commit('RESET_PRODUCTS'); 41 | commit('TRACK_PRODUCTS'); 42 | }, 43 | 44 | // setId: ({ commit }, payload) => commit('SET_ID', payload), 45 | 46 | // Updated 47 | addUpdatedProduct: ({ commit, state }, { id = null, ...productDetails }) => { 48 | // TODO: if the updated prop would lead to its initial state, remove prop! 49 | // if the object has no more props, delete the updated item 50 | const currentUpdatedProduct = state.updatedProducts.get(id) || {}; 51 | const pristineProduct = state.products.get(id); 52 | 53 | const newUpdatedProduct = { ...currentUpdatedProduct, ...productDetails }; 54 | 55 | const actuallyUpdatedProduct = getObjAfterDeletingCommonValues( 56 | newUpdatedProduct, 57 | pristineProduct, 58 | Object.keys(newUpdatedProduct) 59 | ); 60 | 61 | if (actuallyUpdatedProduct === null) { 62 | commit('DELETE_UPDATED_PRODUCT', id); 63 | } else { 64 | commit('ADD_UPDATED_PRODUCT', { id, ...newUpdatedProduct }); 65 | } 66 | 67 | commit('TRACK_UPDATED_PRODUCTS'); 68 | }, 69 | 70 | resetUpdatedProducts: ({ commit }) => { 71 | commit('RESET_UPDATED_PRODUCTS'); 72 | commit('TRACK_UPDATED_PRODUCTS'); 73 | }, 74 | 75 | sendUpdatedProducts: async ({ dispatch, getters, rootState }) => { 76 | const updatedProducts = getters.getUpdatedProducts; 77 | const products = getters.getProducts; 78 | 79 | const url = `${rootState.mainUrl}documents/products`; 80 | const payload = convertMapToObject(updatedProducts); 81 | 82 | const response = await dispatch("api/makePUTRequest", { url, payload }, { root: true }); 83 | 84 | // TODO: decide whether the dashboard info needs to be updated 85 | 86 | const differences = getDiffBetweenMapsElements(products, updatedProducts); 87 | 88 | const message = `Update products in document`; 89 | dispatch('sendHistoryData', { 90 | entity: `document`, 91 | message, 92 | action_type: 'update', 93 | current_state: JSON.stringify(differences), 94 | }); 95 | 96 | dispatch('resetUpdatedProducts'); 97 | 98 | return response; 99 | }, 100 | 101 | // Created 102 | addCreatedProduct: ({ commit }, { id, ...createdProductDetails }) => { 103 | commit('ADD_CREATED_PRODUCT', { id, ...createdProductDetails }); 104 | commit('TRACK_CREATED_PRODUCTS'); 105 | }, 106 | 107 | deleteCreatedProduct: ({ commit }, id) => { 108 | commit('DELETE_CREATED_PRODUCT', id); 109 | commit('TRACK_CREATED_PRODUCTS'); 110 | }, 111 | 112 | resetCreatedProducts: ({ commit }) => { 113 | commit('RESET_CREATED_PRODUCTS'); 114 | commit('TRACK_CREATED_PRODUCTS'); 115 | }, 116 | 117 | addFieldToCreatedProduct: ({ commit, state }, createdProductDetails) => { 118 | const [id, fieldName, fieldValue] = createdProductDetails; 119 | 120 | const currentCreatedProducts = state.createdProducts.get(id) || {}; 121 | const modifiedProduct = { ...currentCreatedProducts, [fieldName]: fieldValue }; 122 | 123 | commit('ADD_CREATED_PRODUCT', { id, ...modifiedProduct }); 124 | commit('TRACK_CREATED_PRODUCTS'); 125 | }, 126 | 127 | sendCreatedProducts: async ({ dispatch, rootGetters, getters }, currentDocumentId) => { 128 | const createdProducts = getters.getCreatedProducts; 129 | const createdProductsWithoutIds = [...createdProducts.values()]; 130 | 131 | const url = `${rootGetters['api/mainURL']}/documents/products`; 132 | const payload = { 133 | createdProducts: createdProductsWithoutIds, 134 | docId: currentDocumentId 135 | }; 136 | 137 | const response = await dispatch('api/makePOSTRequest', { url, payload }, { root: true }); 138 | 139 | const createdProductsForHistory = createdProductsWithoutIds.map(createdItem => { 140 | const { id: randomProductId, product_name: productObj, ...itemWithoutProductObj } = createdItem; 141 | const { id, ...productObjWithoutId } = productObj; 142 | 143 | return { 144 | ...itemWithoutProductObj, 145 | ...productObjWithoutId 146 | }; 147 | }); 148 | 149 | const message = `Add new products in a document`; 150 | dispatch('sendHistoryData', { 151 | entity: `document`, 152 | message, 153 | action_type: 'insert', 154 | current_state: JSON.stringify(createdProductsForHistory) 155 | }); 156 | 157 | dispatch('resetCreatedProducts'); 158 | 159 | return response; 160 | }, 161 | 162 | // Deleted 163 | addDeletedProduct: ({ commit }, { id = null, ...deletedProductDetails }) => { 164 | commit('ADD_DELETED_PRODUCT', { id, ...deletedProductDetails }); 165 | commit('TRACK_DELETED_PRODUCTS'); 166 | 167 | commit('DELETE_PRODUCT', id); 168 | commit('TRACK_PRODUCTS'); 169 | }, 170 | 171 | resetDeletedProducts: ({ commit }) => { 172 | commit('RESET_DELETED_PRODUCTS'); 173 | commit('TRACK_DELETED_PRODUCTS'); 174 | }, 175 | 176 | sendDeletedProducts: async ({ dispatch, rootState, getters }, currentDocumentId) => { 177 | 178 | const deletedProducts = getters.getDeletedProducts; 179 | const shouldDeletedDoc = getters.getCreatedProducts.size + getters.getProducts.size === 0 180 | 181 | const url = `${rootState.mainUrl}documents/products`; 182 | const payload = { 183 | ids: [...deletedProducts.keys()].map(k => deletedProducts.get(k).product_id), 184 | docId: currentDocumentId, 185 | shouldDeleteDoc: shouldDeletedDoc 186 | }; 187 | 188 | const response = await dispatch('api/makeDELETERequest', { url, payload }, { root: true }); 189 | 190 | const deletedProductsForHistory = convertMapToArrExcludingProps(getters.getDeletedProducts, ['document_id', 'product_id']); 191 | 192 | dispatch('sendHistoryData', { 193 | entity: 'document', 194 | message: 'Delete products from document', 195 | action_type: 'delete', 196 | prev_state: JSON.stringify(deletedProductsForHistory) 197 | }); 198 | 199 | dispatch('resetDeletedProducts'); 200 | 201 | return { ...response, shouldDeletedDoc }; 202 | }, 203 | 204 | updateDocument: async ( 205 | { dispatch, rootState, rootGetters }, 206 | { docId, provider_id, newDocumentData, oldDocumentData }, 207 | ) => { 208 | const url = `${rootState.mainUrl}documents/update_document`; 209 | 210 | const config = { 211 | ...rootGetters['api/config'], 212 | method: "PUT", 213 | body: JSON.stringify({ 214 | provider_id, 215 | invoice_number: newDocumentData.invoice_number, 216 | id: docId, 217 | }) 218 | } 219 | 220 | const response = await dispatch('api/makeRequest', { url, config }, { root: true }) 221 | 222 | const currentState = JSON.stringify({ 223 | [docId]: { 224 | from: oldDocumentData, 225 | to: newDocumentData, 226 | }, 227 | }); 228 | 229 | const message = `Update document information`; 230 | dispatch('dashboard/insertHistoryRow', { 231 | entity: `document`, 232 | message, 233 | action_type: 'update', 234 | current_state: currentState 235 | }, { root: true }); 236 | 237 | return response; 238 | }, 239 | 240 | // setAlreadyFetched: ({ commit }, payload) => commit('SET_ALREADY_FETCHED', payload), 241 | 242 | sendHistoryData: async ({ dispatch }, historyData) => { 243 | dispatch('dashboard/insertHistoryRow', historyData, { root: true }); 244 | }, 245 | 246 | fetchOneDocument: async ({ dispatch, commit }, url) => { 247 | const updatedDocument = (await dispatch('api/makeGETRequest', { url }, { root: true }))[0]; 248 | 249 | commit('document/ADD_ITEM', updatedDocument, { root: true }); 250 | commit('document/TRACK_ITEMS', undefined, { root: true }); 251 | }, 252 | } -------------------------------------------------------------------------------- /client/src/store/modules/single-document/single-document.getters.js: -------------------------------------------------------------------------------- 1 | import { convertMapToArr } from '@/utils/'; 2 | 3 | export const getters = { 4 | 5 | // ? needs a deeper look 6 | // getItemsById: state => state.items.filter(({ 7 | // document_id 8 | // }) => document_id === state.currentId), 9 | 10 | // getChanges: state => return state.changes, 11 | 12 | // getPristineData: state => state.pristineData, 13 | 14 | // getDeletedItems: state => state.deletedItems, 15 | // ? ============================= 16 | 17 | 18 | getProductsAsArr: state => state.productsTracker && convertMapToArr(state.products, 'id'), 19 | 20 | getCreatedProductsAsArr: state => state.createdProductsTracker && convertMapToArr(state.createdProducts, 'id'), 21 | 22 | getUpdatedProducts: state => state.updatedProductsTracker && state.updatedProducts, 23 | 24 | getDeletedProducts: state => state.deletedProductsTracker && state.deletedProducts, 25 | 26 | getCreatedProducts: state => state.createdProductsTracker && state.createdProducts, 27 | 28 | getProducts: state => state.productsTracker && state.products, 29 | 30 | getWhetherItShouldEnableConfirmBtn: state => 31 | state.updatedProductsTracker && state.updatedProducts.size !== 0 32 | || state.deletedProductsTracker && state.deletedProducts.size !== 0 33 | || state.createdProductsTracker && state.createdProducts.size !== 0 34 | || state.currentDocumentNewData !== null, 35 | 36 | getExistingProductsIds: state => { 37 | if (!state.productsTracker) 38 | return; 39 | 40 | const productsIds = {}; 41 | 42 | for (const p of state.products.values()) { 43 | productsIds[p.product_id] = true; 44 | } 45 | 46 | return productsIds; 47 | }, 48 | 49 | getCreatedProductsIds: state => { 50 | if (!state.createdProductsTracker) 51 | return; 52 | 53 | const createdProductsIds = {}; 54 | 55 | for (const p of state.createdProducts.values()) { 56 | if (p.product_name && p.product_name.id) { 57 | createdProductsIds[p.product_name.id] = true; 58 | } 59 | } 60 | 61 | return createdProductsIds; 62 | }, 63 | 64 | getHasDocumentDataChanged: state => state.currentDocumentNewData !== null, 65 | } -------------------------------------------------------------------------------- /client/src/store/modules/single-document/single-document.mutations.js: -------------------------------------------------------------------------------- 1 | import { 2 | getObjAfterDeletingCommonValues, 3 | } from '@/utils/'; 4 | 5 | export const mutations = { 6 | SET_ITEMS: (state, payload) => state.items = payload, 7 | 8 | SET_ID: (state, id) => state.currentId = id, 9 | 10 | SET_CHANGES: (state, payload) => state.changes = payload, 11 | 12 | SET_ALREADY_FETCHED: (state, payload) => state.alreadyFetched = payload, 13 | 14 | SET_LAST_DELETED_DOC_ID: (state, payload) => state.lastDeletedDocId = payload, 15 | 16 | // SET_PRISTINE_DATA: (state, { 17 | // id, 18 | // ...fields 19 | // }) => state.pristineData.set(id, fields), 20 | 21 | // Existing 22 | ADD_PRODUCT: (state, { id, ...productDetails }) => state.products.set(id, productDetails), 23 | 24 | TRACK_PRODUCTS: state => state.productsTracker++, 25 | 26 | DELETE_PRODUCT: (state, id) => state.products.delete(id), 27 | 28 | RESET_PRODUCTS: state => state.products.clear(), 29 | 30 | // Updated 31 | ADD_UPDATED_PRODUCT: (state, { id, ...productDetails }) => state.updatedProducts.set(id, productDetails), 32 | 33 | TRACK_UPDATED_PRODUCTS: state => state.updatedProductsTracker++, 34 | 35 | RESET_UPDATED_PRODUCTS: state => state.updatedProducts.clear(), 36 | 37 | // Created 38 | ADD_CREATED_PRODUCT: (state, { id, ...newProductDetails }) => state.createdProducts.set(id, newProductDetails), 39 | 40 | TRACK_CREATED_PRODUCTS: state => state.createdProductsTracker++, 41 | 42 | RESET_CREATED_PRODUCTS: state => state.createdProducts.clear(), 43 | 44 | DELETE_CREATED_PRODUCT: (state, id) => state.createdProducts.delete(id), 45 | 46 | // Deleted 47 | ADD_DELETED_PRODUCT: (state, { id, ...deletedProductDetails }) => state.deletedProducts.set(id, deletedProductDetails), 48 | 49 | TRACK_DELETED_PRODUCTS: state => state.deletedProductsTracker++, 50 | 51 | RESET_DELETED_PRODUCTS: state => state.deletedProducts.clear(), 52 | 53 | DELETE_UPDATED_PRODUCT: (state, id) => state.updatedProducts.delete(id), 54 | 55 | SET_CURRENT_DOCUMENT_OWN_PRISTINE_DATA: (state, docData) => { 56 | state.currentDocumentOwnPristineData = docData; 57 | }, 58 | 59 | SET_CURRENT_DOCUMENT_NEW_DATA: (state, newDocData) => { 60 | const newActualDocData = getObjAfterDeletingCommonValues( 61 | newDocData, 62 | state.currentDocumentOwnPristineData, 63 | Object.keys(newDocData), 64 | ); 65 | 66 | if (!newActualDocData) { 67 | state.currentDocumentNewData = null; 68 | 69 | return; 70 | } 71 | 72 | state.currentDocumentNewData = { 73 | ...state.currentDocumentNewData, 74 | ...newActualDocData 75 | }; 76 | 77 | }, 78 | 79 | RESET_DOCUMENT_DATA: state => { 80 | state.currentDocumentOwnPristineData = state.currentDocumentNewData = null; 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /client/src/store/modules/user/index.js: -------------------------------------------------------------------------------- 1 | export const namespaced = true; 2 | 3 | export const state = { 4 | currentUser: localStorage.getItem('currentUser') && JSON.parse(localStorage.getItem('currentUser')) || null, 5 | 6 | userUrl: 'auth/', 7 | }; 8 | 9 | export { mutations } from './user.mutations'; 10 | 11 | export { actions } from './user.actions'; -------------------------------------------------------------------------------- /client/src/store/modules/user/user.actions.js: -------------------------------------------------------------------------------- 1 | export const actions = { 2 | attemptRegister: async ({ state, rootState, dispatch, commit }, payload) => { 3 | console.log('attempt register') 4 | 5 | const url = rootState.mainUrl + state.userUrl + 'register'; 6 | 7 | const response = await dispatch('api/makePOSTRequest', { url, payload }, { root: true } 8 | ); 9 | 10 | console.log('response', response) 11 | 12 | if (response.err) { 13 | console.log('err'); 14 | 15 | return false; 16 | } 17 | 18 | commit('SET_USER', response); 19 | return true; 20 | }, 21 | 22 | attemptLogin: async ({ state, rootState, dispatch, commit }, payload) => { 23 | const url = rootState.mainUrl + state.userUrl + 'login'; 24 | 25 | const response = await dispatch('api/makePOSTRequest', { url, payload }, { root: true } 26 | ); 27 | 28 | console.log('response', response) 29 | 30 | if (response.err) { 31 | console.log('err'); 32 | 33 | return false; 34 | } 35 | 36 | commit('SET_USER', response); 37 | return true; 38 | }, 39 | 40 | logout: ({ commit }) => { 41 | commit('SET_USER', null); 42 | } 43 | }; -------------------------------------------------------------------------------- /client/src/store/modules/user/user.mutations.js: -------------------------------------------------------------------------------- 1 | export const mutations = { 2 | SET_USER: (state, user) => { 3 | const updatedUser = { 4 | ...state.currentUser, 5 | ...user, 6 | }; 7 | 8 | state.currentUser = updatedUser; 9 | 10 | if (user) { 11 | localStorage.setItem('currentUser', JSON.stringify(updatedUser)); 12 | } else { 13 | localStorage.removeItem('currentUser'); 14 | } 15 | }, 16 | }; -------------------------------------------------------------------------------- /client/src/styles/base.scss: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | } 11 | 12 | html, body { 13 | width: 100vw; 14 | height: 100vh; 15 | font-family: 'Open Sans', sans-serif; 16 | font-size: .95rem; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/styles/common.scss: -------------------------------------------------------------------------------- 1 | $main-color: #394263; 2 | 3 | .icon { 4 | 5 | &--add-row { 6 | padding: .9rem; 7 | max-width: 40px; 8 | transform: translateY(35%); 9 | 10 | svg { 11 | width: 32px; 12 | height: 32px; 13 | color: green; 14 | background-color: darken($color: #fff, $amount: 8%); 15 | } 16 | } 17 | 18 | svg { 19 | 20 | border-radius: 50%; 21 | cursor: pointer; 22 | 23 | &:hover { 24 | transform: scale(1.1); 25 | } 26 | } 27 | } 28 | 29 | .no-items { 30 | text-align: center; 31 | color: $main-color; 32 | font-size: 2rem; 33 | margin-top: 3rem; 34 | } -------------------------------------------------------------------------------- /client/src/styles/helpers.scss: -------------------------------------------------------------------------------- 1 | .h-icon-wrapper { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | cursor: pointer; 6 | height: 1rem; 7 | width: 1rem; 8 | padding: .6rem; 9 | border-radius: 50%; 10 | transform: translateY(-20%); 11 | 12 | svg { 13 | color: red; 14 | } 15 | 16 | &:hover { 17 | background-color: #ccc; 18 | } 19 | } 20 | 21 | .h-perfectly-centerd { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | } 26 | 27 | .h-fullscreen { 28 | height: 100%; 29 | width: 100%; 30 | } -------------------------------------------------------------------------------- /client/src/styles/mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin profile-avatar { 2 | background-image: url("https://image.freepik.com/free-icon/important-person_318-10744.jpg"); 3 | background-size: cover; 4 | background-repeat: no-repeat; 5 | border-radius: 50%; 6 | -webkit-border-radius: 50%; 7 | -moz-border-radius: 50%; 8 | -ms-border-radius: 50%; 9 | -o-border-radius: 50%; 10 | border: 2px solid rgba(255, 255, 255, .2); 11 | } -------------------------------------------------------------------------------- /client/src/styles/modal.scss: -------------------------------------------------------------------------------- 1 | .modal-body { 2 | 3 | &__value, 4 | &__prop { 5 | display: inline-block; 6 | // On smaller screens: 1 0 10rem; 7 | flex: 1 1 45%; 8 | } 9 | 10 | &__arrow { 11 | font-size: 1.2rem; 12 | } 13 | 14 | &__prop { 15 | text-align: left; 16 | 17 | span { 18 | background-color: $modal-text-color; 19 | color: #fff; 20 | padding: 5px; 21 | font-size: 1.1rem; 22 | letter-spacing: 1px; 23 | border-radius: 5px; 24 | } 25 | } 26 | 27 | &__value { 28 | color: $modal-text-color; 29 | font-size: 1.2rem; 30 | font-style: italic; 31 | font-weight: bold; 32 | text-align: right; 33 | padding-right: 1rem; 34 | } 35 | 36 | &__row { 37 | display: flex; 38 | justify-content: space-between; 39 | align-items: center; 40 | padding: 5px 1rem; 41 | } 42 | 43 | &__row:not(:last-child) { 44 | margin-bottom: 6px; 45 | } 46 | } 47 | 48 | .c-modal-buttons { 49 | display: flex; 50 | justify-content: space-between; 51 | align-items: flex; 52 | 53 | &__button { 54 | border-radius: 7px; 55 | -webkit-border-radius: 7px; 56 | -moz-border-radius: 7px; 57 | -ms-border-radius: 7px; 58 | -o-border-radius: 7px; 59 | color: #fff; 60 | font-size: 1rem; 61 | padding: .4rem; 62 | border: none; 63 | cursor: pointer; 64 | 65 | &:hover { 66 | transform: scale(1.1); 67 | -webkit-transform: scale(1.1); 68 | -moz-transform: scale(1.1); 69 | -ms-transform: scale(1.1); 70 | -o-transform: scale(1.1); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /client/src/styles/table.scss: -------------------------------------------------------------------------------- 1 | .table { 2 | border-collapse: collapse; 3 | background-color: #FFF; 4 | border-radius: 13px; 5 | width: 100%; 6 | 7 | td, th { 8 | padding: 15px; 9 | text-align: center; 10 | } 11 | 12 | thead { 13 | background-color: darken($color: #394263, $amount: 5%); 14 | color: #fff; 15 | font-weight: 300; 16 | 17 | tr { 18 | 19 | th:not(:last-child) { 20 | border-right: 1px solid #ccc; 21 | } 22 | 23 | th:first-child { 24 | border-top-left-radius: 13px; 25 | } 26 | 27 | th:last-child { 28 | border-top-right-radius: 13px; 29 | } 30 | } 31 | } 32 | 33 | tbody { 34 | tr > td:not(:last-child) { 35 | border-right: 1px solid #ccc; 36 | } 37 | 38 | tr:last-child { 39 | 40 | td:first-child { 41 | border-bottom-left-radius: 13px; 42 | } 43 | 44 | td:last-child { 45 | border-bottom-right-radius: 13px; 46 | } 47 | } 48 | 49 | tr:not(:last-child) { 50 | border-bottom: 1px solid #ccc; 51 | } 52 | 53 | tr td { 54 | outline: 0; 55 | border: none; 56 | padding: 15px 1px; 57 | } 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /client/src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | $sidebar-color: #394263; 2 | $link-color: #EAEDF1; -------------------------------------------------------------------------------- /client/src/utils/date.js: -------------------------------------------------------------------------------- 1 | export const SECOND = 1000; 2 | export const MINUTE = 60 * SECOND; 3 | export const HOUR = MINUTE * 60; 4 | export const DAY = 24 * HOUR; 5 | 6 | /** 7 | * 8 | * @param {Date} startDate 9 | * @param {number} intervalOfDays 10 | * @param {Date} crtDate 11 | */ 12 | export const getMsUntilNextEvent = (startDate, intervalOfDays, crtDate) => { 13 | const msPassedUntilCrtDate = crtDate.getTime() - startDate.getTime(); 14 | const windowsCount = Math.ceil(msPassedUntilCrtDate / getNDaysAsMS(intervalOfDays)); 15 | 16 | const nextDateWhenEventOccurs = new Date(startDate.getTime()); 17 | nextDateWhenEventOccurs.setDate(nextDateWhenEventOccurs.getDate() + windowsCount * intervalOfDays); 18 | 19 | return nextDateWhenEventOccurs.getTime() - crtDate.getTime(); 20 | }; 21 | 22 | export const getNDaysAsMS = n => DAY * n; 23 | 24 | export const formatDateFromMs = ms => { 25 | const days = Math.floor(ms / DAY); 26 | const hours = Math.floor((ms % DAY) / HOUR); 27 | const minutes = Math.floor(((ms % DAY) % HOUR) / MINUTE); 28 | const seconds = Math.floor((((ms % DAY) % HOUR) % MINUTE) / SECOND); 29 | 30 | return { 31 | days, 32 | hours, 33 | minutes, 34 | seconds, 35 | }; 36 | }; -------------------------------------------------------------------------------- /client/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export const capitalize = ([first, ...rest]) => `${first.toUpperCase()}${rest.join('')}` 2 | 3 | // eslint-disable-next-line 4 | export const getRidOfObjProp = (obj, prop, { [prop]: _, ...rest } = obj) => rest 5 | 6 | /** 7 | * @example 8 | * returns "30/03/2019" 9 | * formDate("2019-03-30T15:34:59.000Z") 10 | */ 11 | export const formatDate = dateStr => dateStr.replace(/(?\d{4})\-(?\d{2})\-(?\d{2})([a-zA-Z:0-9.]+)/, '$/$/$') 12 | 13 | // Not using arrow function because we need to bind `this`(Vue instance) 14 | export const fetchExcelFile = async function (url, id) { 15 | let link; 16 | 17 | await this.$store.dispatch('singleDocument/fetchProductsByDocumentId', id); 18 | 19 | const config = { 20 | headers: new Headers({ 21 | 'Content-type': 'application/json', 22 | 'x-access-token': this.$store.state.user.currentUser.token 23 | }), 24 | method: "POST", 25 | body: JSON.stringify({ 26 | fileType: 'excel', 27 | id, 28 | vat: this.$store.getters['dashboard/getCurrentVat'], 29 | products: this.$store.getters['singleDocument/getProductsAsArr'], 30 | }) 31 | } 32 | 33 | return fetch(url, config) 34 | .then(res => res.arrayBuffer()) 35 | .then(res => { 36 | 37 | console.log('res', res) 38 | 39 | const blob = new Blob([res], {type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}); 40 | 41 | link = document.createElement('a'); 42 | link.href = window.URL.createObjectURL(blob); 43 | link.download = `document${id}.xlsx`; 44 | link.click(); 45 | link = null; 46 | }) 47 | .finally(() => { 48 | link = null; 49 | }) 50 | } 51 | 52 | export const formatColumnName = column => column.split('_').map(capitalize).join(' '); 53 | 54 | /** 55 | * Receives an array of objects and determines whether any item has empty values 56 | * 57 | * @param {Array} arr 58 | */ 59 | export const hasEmptyValues = arr => arr.some(obj => Object.values(obj).some(val => typeof val !== 'object' && val.trim() === '')) 60 | 61 | /** 62 | * Used mostly when parsing data that is stored in History table 63 | * in order to show the user the previous and current states 64 | * 65 | * @param {String} kvPair 66 | * @param {String} separator 67 | * 68 | * @example 69 | * returns ['name', 'mozzarella'] 70 | * separateValues('name:mozzarella', ':') 71 | */ 72 | export const separateValues = (kvPair, separator) => { 73 | const sepIndex = kvPair.indexOf(separator); 74 | const key = kvPair.slice(0, sepIndex); 75 | const value = kvPair.slice(sepIndex + 1); 76 | 77 | return [key, value]; 78 | } 79 | 80 | export const compareObjects = (pristineObj, changedObj, cbWhenChangeFound = undefined) => { 81 | return Object.entries(changedObj) 82 | .reduce((changes, [key, value]) => { 83 | if (`${pristineObj[key]}` !== `${value}`) { 84 | changes[key] = value 85 | 86 | if (cbWhenChangeFound !== undefined) 87 | cbWhenChangeFound(pristineObj, changedObj, key); 88 | } 89 | 90 | return changes; 91 | }, {}) 92 | }; 93 | 94 | export const isObjectEmpty = obj => Object.keys(obj).length === 0 95 | 96 | export const convertMapToArr = (m, keyName = null, itemsToAvoid = null, itemsToReplace = null) => { 97 | if (!m.size) 98 | return []; 99 | 100 | const items = []; 101 | 102 | for (const [k, v] of m) { 103 | if (itemsToAvoid && itemsToAvoid.has(k)) 104 | continue; 105 | 106 | items.push({ 107 | ...keyName && { [keyName]: k }, 108 | ...v, 109 | ...itemsToReplace && itemsToReplace.has(k) && itemsToReplace.get(k) 110 | }); 111 | } 112 | 113 | return items; 114 | }; 115 | 116 | export const convertMapToObject = m => { 117 | const obj = {}; 118 | 119 | for (const [id, itemDetails] of m) { 120 | obj[id] = itemDetails; 121 | } 122 | 123 | return obj; 124 | }; 125 | 126 | 127 | /** 128 | * @returns 129 | * // - the arrays id of updated items 130 | * - the column names (the union of column names) 131 | * - an object which can be represented like this 132 | * ```javascript 133 | * const exampleObject = { 134 | * 'columnName': { 135 | * '3': 'value of `column name` for the item with the index 3' 136 | * } 137 | * }; 138 | * ``` 139 | */ 140 | export const convertMapToObjForAPI = m => { 141 | const result = {}; 142 | const columnNames = new Set(); 143 | 144 | for (const [id, itemDetails] of m) { 145 | result[id] = {}; 146 | 147 | for (const columnName in itemDetails) { 148 | columnNames.add(columnName); 149 | 150 | result[id][columnName] = itemDetails[columnName]; 151 | } 152 | } 153 | 154 | return [result, columnNames]; 155 | }; 156 | 157 | /** 158 | * TODO: refactor explanation 159 | * Check if any value from m1 has a prop that is the same as one key of m2, 160 | * based on a given prop 161 | * 162 | * ```javascript 163 | * m1 = [[1, { name: 'andrei', product_id: 7 }], [][2, { name: 'john', product_id: 8 }]] 164 | * m2 = [[2, { productInfo2 }], [7, { productInfo7 }], [8, productInfo8]] 165 | * prop = 'product_info' 166 | * 167 | * canJoinMapsBasedOnProp(m1, m2, prop) 168 | * ``` 169 | * @returns true 170 | */ 171 | export const canJoinMapsBasedOnProp = (m1, m2, prop) => { 172 | for (const [k, v] of m1) { 173 | if (m2.has(v[prop])) 174 | return true; 175 | } 176 | 177 | return false; 178 | }; 179 | 180 | export const getPropertiesOfNestedObj = obj => { 181 | for (const p in obj) { 182 | return Object.keys(obj[p]); 183 | } 184 | }; 185 | 186 | /** 187 | * ```typescript 188 | * return { [id: number]: { from: { ... }, to: { ... } } } 189 | * ``` 190 | */ 191 | export const getDiffBetweenMapsElements = (from, to) => { 192 | const result = {}; 193 | 194 | for (const [k, v] of to) { 195 | result[k] = {}; 196 | result[k]['from'] = {}; 197 | result[k]['to'] = {}; 198 | 199 | for (const objProp in v) { 200 | result[k]['from'][objProp] = from.get(k)[objProp]; 201 | result[k]['to'][objProp] = v[objProp]; 202 | } 203 | } 204 | 205 | return result; 206 | }; 207 | 208 | export const convertMapToArrExcludingProps = (m, propsToExclude) => { 209 | const result = []; 210 | 211 | for (const [k, item] of m) { 212 | let newObj = {}; 213 | 214 | for (const prop in item) { 215 | if (propsToExclude.includes(prop)) 216 | continue; 217 | 218 | newObj[prop] = item[prop]; 219 | } 220 | 221 | result.push(newObj); 222 | } 223 | 224 | return result; 225 | } 226 | 227 | export const getObjAfterDeletingCommonValues = (o1, o2, keys) => { 228 | const result = {}; 229 | let isResultEmpty = true; 230 | 231 | for (const k of keys) { 232 | if (`${o1[k]}`.trim() !== `${o2[k]}`.trim() && !!o1[k]) { 233 | result[k] = o1[k]; 234 | isResultEmpty = false; 235 | } 236 | } 237 | 238 | return isResultEmpty ? null : result; 239 | }; -------------------------------------------------------------------------------- /client/src/views/AuthAfter.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 36 | 37 | 64 | -------------------------------------------------------------------------------- /client/src/views/DocumentHome.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | 39 | -------------------------------------------------------------------------------- /client/src/views/File.vue: -------------------------------------------------------------------------------- 1 |