├── assets ├── css │ ├── message.css │ ├── greeting.css │ ├── footer.css │ ├── index.css │ ├── stock-update-modal.css │ ├── loginform.css │ ├── paginate.css │ ├── data-table.css │ └── vendor │ │ └── bootstrap-4.0.0 │ │ ├── bootstrap-reboot.min.css │ │ └── bootstrap-reboot.css └── js │ ├── greeting.jsx │ ├── footer.jsx │ ├── message.jsx │ ├── env.jsx │ ├── paginate.jsx │ ├── loginform.jsx │ ├── data-table.jsx │ ├── index.jsx │ └── stock-update-modal.jsx ├── src ├── css │ ├── message.css │ ├── greeting.css │ ├── app-name-title.css │ ├── loginform.css │ ├── footer.css │ ├── index.css │ ├── header.css │ ├── paginate.css │ ├── stock-update-modal.css │ ├── data-table.css │ └── vendor │ │ └── bootstrap-4.0.0 │ │ ├── bootstrap-reboot.min.css │ │ └── bootstrap-reboot.css ├── version.js ├── index.js ├── app-name-title.js ├── greeting.js ├── footer.js ├── data-table-caption.js ├── message.js ├── header.js ├── setupTests.js ├── data-table-nav.js ├── data-table-head.js ├── img │ └── box.svg ├── truck-table.js ├── stock-update-delete.js ├── data-table.js ├── truck.js ├── data-table-data.js ├── paginate.js ├── loginform.js ├── api.js ├── stock-update-modal.js └── api-request.js ├── meta └── img │ ├── screenshot_1.png │ ├── screenshot_10.png │ ├── screenshot_2.png │ ├── screenshot_3.png │ ├── screenshot_4.png │ ├── screenshot_5.png │ ├── screenshot_6.png │ ├── screenshot_7.png │ ├── screenshot_8.png │ └── screenshot_9.png ├── public ├── .htaccess ├── manifest.json └── index.html ├── .gitignore ├── webpack-stats.json ├── .env.production_DEFAULT ├── webpack.config.js ├── package.json ├── prod.webpack.config.js └── README.md /assets/css/message.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/css/message.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/css/greeting.css: -------------------------------------------------------------------------------- 1 | div.container { 2 | vertical-align:middle!important; 3 | } -------------------------------------------------------------------------------- /assets/css/greeting.css: -------------------------------------------------------------------------------- 1 | div.container { 2 | vertical-align:middle!important; 3 | } -------------------------------------------------------------------------------- /src/version.js: -------------------------------------------------------------------------------- 1 | const APP_VERSION = '[client 4.1.8.2]'; 2 | export default APP_VERSION; 3 | -------------------------------------------------------------------------------- /assets/css/footer.css: -------------------------------------------------------------------------------- 1 | div.footer { 2 | font-size: 0.75em; 3 | margin: auto; 4 | text-align: center; 5 | } -------------------------------------------------------------------------------- /meta/img/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_1.png -------------------------------------------------------------------------------- /meta/img/screenshot_10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_10.png -------------------------------------------------------------------------------- /meta/img/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_2.png -------------------------------------------------------------------------------- /meta/img/screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_3.png -------------------------------------------------------------------------------- /meta/img/screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_4.png -------------------------------------------------------------------------------- /meta/img/screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_5.png -------------------------------------------------------------------------------- /meta/img/screenshot_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_6.png -------------------------------------------------------------------------------- /meta/img/screenshot_7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_7.png -------------------------------------------------------------------------------- /meta/img/screenshot_8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_8.png -------------------------------------------------------------------------------- /meta/img/screenshot_9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ConsciousUniverse/simple-stock-management-frontend/HEAD/meta/img/screenshot_9.png -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | Options -MultiViews 2 | RewriteEngine On 3 | RewriteCond %{REQUEST_FILENAME} !-f 4 | RewriteRule ^ index.html [QSA,L] -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './app.js'; 4 | 5 | ReactDOM.render( 6 | , document.getElementById('root')); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | build/ 3 | assets/bundles/ 4 | node_modules/ 5 | tests/ 6 | .eslintcache 7 | *swp 8 | package-lock.json 9 | devel/ 10 | nvm/ 11 | .env.production 12 | .env.dev 13 | *~ 14 | -------------------------------------------------------------------------------- /webpack-stats.json: -------------------------------------------------------------------------------- 1 | {"status":"done","chunks":{"main":[{"name":"main-50730b48ae5ab175c0ab.js","path":"/home/dan/Data/LocalRepositories/DEVELOPMENT/StockManagement/stock_control_frontend/react/assets/bundles/main-50730b48ae5ab175c0ab.js"}]}} -------------------------------------------------------------------------------- /assets/css/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: #001e00; 3 | } 4 | 5 | div.app-main { 6 | padding: 1em; 7 | color: #fcfcfc; 8 | background-color: #001a00; 9 | margin: auto; 10 | } 11 | 12 | h1, h2, h3, h4, h5, h6 { 13 | color: yellow; 14 | } -------------------------------------------------------------------------------- /src/css/app-name-title.css: -------------------------------------------------------------------------------- 1 | div.container { 2 | vertical-align:middle!important; 3 | } 4 | 5 | span.appTitle { 6 | display: block; 7 | transform: rotateX(360deg) scale(1); 8 | transition: all 2000ms ease-in-out; 9 | } 10 | 11 | span.appTitle:hover { 12 | } -------------------------------------------------------------------------------- /src/app-name-title.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './css/app-name-title.css'; 3 | /* 4 | Functional component to display the greeting 5 | */ 6 | const AppNameTitle = () => { 7 | return ( 8 |
{process.env.REACT_APP_NAME_TITLE}
9 | ); 10 | }; 11 | export default AppNameTitle; -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Simple Stock Management", 3 | "name": "Simple Stock Management App, by dan@uplandsdynamic.com", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#000000" 15 | } 16 | -------------------------------------------------------------------------------- /src/css/loginform.css: -------------------------------------------------------------------------------- 1 | div.login div.form-field { 2 | display: inline-block; 3 | margin: 0 1em 1em 0; 4 | } 5 | 6 | div.login div.logout-button-field { 7 | text-align: right; 8 | } 9 | 10 | div.login input.login { 11 | display: block; 12 | } 13 | 14 | span.welcome { 15 | display: inline-block; 16 | color: #001e00; 17 | margin: 0 1em 0 1em; 18 | } 19 | 20 | .logout-button-field button { 21 | margin: 0.25em; 22 | } -------------------------------------------------------------------------------- /src/css/footer.css: -------------------------------------------------------------------------------- 1 | div.footer { 2 | padding: 1vh; 3 | margin: auto auto 1vh auto; 4 | font-size: 0.75em; 5 | text-align: center; 6 | color: silver; 7 | background-color:teal; 8 | -webkit-border-bottom-right-radius: 45px; 9 | -webkit-border-bottom-left-radius: 45px; 10 | -moz-border-radius-bottomright: 45px; 11 | -moz-border-radius-bottomleft: 45px; 12 | border-bottom-right-radius: 45px; 13 | border-bottom-left-radius: 45px; 14 | } -------------------------------------------------------------------------------- /assets/css/stock-update-modal.css: -------------------------------------------------------------------------------- 1 | button.close-button { 2 | margin-left:auto; 3 | } 4 | 5 | .close-modal-button-cell { 6 | text-align: right; 7 | } 8 | 9 | label { 10 | margin: 0.5em; 11 | font-weight: bold; 12 | } 13 | 14 | form.transfer-form { 15 | background-color: yellow; 16 | color: #001e00; 17 | } 18 | 19 | form.transfer-form-danger { 20 | background-color: #530000; 21 | color: yellow; 22 | } 23 | 24 | button.delete-btn, button.cancel-btn { 25 | margin: 0.25em; 26 | } -------------------------------------------------------------------------------- /.env.production_DEFAULT: -------------------------------------------------------------------------------- 1 | REACT_APP_API_ROUTE = 'https://mydomain.tld/api' 2 | REACT_APP_API_DATA_ROUTE = 'https://mydomain.tld/api/v2' 3 | REACT_APP_ORG_NAME = 'My Organisation name' 4 | REACT_APP_SHORT_ORG_NAME = 'Org Name' 5 | REACT_APP_COPYRIGHT = 'Application developed by Uplands Dynamic, apps@uplandsdynamic.com' 6 | REACT_APP_FOOTER = 'My app footer' 7 | REACT_APP_NAME_TITLE = 'Simple Stock Management' 8 | REACT_APP_ROWS_PER_TABLE = 25 9 | REACT_APP_PAGER_MAIN_SIZE = 5 10 | REACT_APP_PAGER_END_SIZE = 3 11 | REACT_APP_VERSION = '4.0.0' 12 | -------------------------------------------------------------------------------- /assets/js/greeting.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '../css/greeting.css' 3 | /* 4 | Functional component to display the greeting (passed in as prop from loginform.jsx, set in state 5 | in index.jsx). 6 | */ 7 | const Greeting = ({greeting = ''} = {}) => { 8 | return ( 9 |
10 |
11 |
12 | {greeting} 13 |
14 |
15 |
16 | ); 17 | }; 18 | export default Greeting; -------------------------------------------------------------------------------- /src/greeting.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './css/greeting.css' 3 | /* 4 | Functional component to display the greeting (passed in as prop from loginform.js, set in state 5 | in index.js). 6 | */ 7 | const Greeting = ({greeting = ''} = {}) => { 8 | return ( 9 |
10 |
11 |
12 | {greeting} 13 |
14 |
15 |
16 | ); 17 | }; 18 | export default Greeting; -------------------------------------------------------------------------------- /src/css/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | /* background-color: #001e00; */ 3 | height: 100%; 4 | } 5 | 6 | body { 7 | background-color: #333; 8 | } 9 | 10 | div.app-main { 11 | margin: 1vh 1vh 0 1vh; 12 | padding: 1vh; 13 | color: #fcfcfc; 14 | background-color: #001a00; 15 | min-height: 90vh; 16 | -webkit-border-radius: 15px; 17 | -moz-border-radius: 15px; 18 | border-radius: 15px; 19 | } 20 | 21 | h1, h2, h3, h4, h5, h6 { 22 | color: yellow; 23 | } 24 | 25 | div.account_type h1 { 26 | color:#001a00; 27 | } 28 | 29 | button.table-btn { 30 | transition: all 200ms ease-in-out 31 | } 32 | 33 | button.table-btn:hover { 34 | transform: scale(1.25); 35 | z-index: 10; 36 | } -------------------------------------------------------------------------------- /assets/css/loginform.css: -------------------------------------------------------------------------------- 1 | div.login { 2 | margin: 1em auto 1em auto; 3 | padding: 1em; 4 | background-color: yellow; 5 | -webkit-border-radius: 3px; 6 | -moz-border-radius: 3px; 7 | border-radius: 3px; 8 | font-weight: bold; 9 | vertical-align: middle!important; 10 | color: #001e00; 11 | } 12 | 13 | div.login div.form-field { 14 | display: inline-block; 15 | margin: 0 1em 1em 0; 16 | } 17 | 18 | div.login div.logout-button-field { 19 | text-align: right; 20 | } 21 | 22 | div.login input.login { 23 | display: block; 24 | } 25 | 26 | span.welcome { 27 | display: inline-block; 28 | color: #001e00; 29 | margin: 0 1em 0 1em; 30 | } 31 | 32 | .logout-button-field button { 33 | margin: 0.25em; 34 | } -------------------------------------------------------------------------------- /src/css/header.css: -------------------------------------------------------------------------------- 1 | div.header { 2 | margin: 1em auto 1em auto; 3 | padding: 1em; 4 | background-color: yellow; 5 | -webkit-border-radius: 3px; 6 | -moz-border-radius: 3px; 7 | border-radius: 3px; 8 | font-weight: bold; 9 | vertical-align: middle !important; 10 | color: #001e00; 11 | } 12 | 13 | .logo { 14 | animation: Box-logo-scale 500ms 6 alternate-reverse ease-in-out; 15 | color: transparent; /* hides alt text while image loading */ 16 | transition: all 500ms ease-in-out; 17 | } 18 | 19 | .logo:hover { 20 | transform: rotate(360deg); 21 | } 22 | 23 | @keyframes Box-logo-scale { 24 | from { 25 | transform: scale3d(0,0,0) 26 | } 27 | to { 28 | transform: scale3d(1,1,1); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/js/footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import '../css/footer.css' 3 | /* 4 | Functional component to display the greeting (passed in as prop from loginform.jsx, set in state 5 | in index.jsx). 6 | */ 7 | const Footer = ({copyright = '', footer = '', version = ''} = {}) => { 8 | return ( 9 |
10 |
11 |
12 | {footer} 13 |
14 |
15 |
16 |
17 | {copyright} 18 |
19 |
20 |
21 |
22 | {version} 23 |
24 |
25 |
26 | ); 27 | }; 28 | export default Footer; -------------------------------------------------------------------------------- /src/footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './css/footer.css' 3 | /* 4 | Functional component to display the greeting (passed in as prop from loginform.js, set in state 5 | in index.js). 6 | */ 7 | const Footer = ({copyright = '', footer = '', version = ''} = {}) => { 8 | return ( 9 |
10 |
11 |
12 | {footer} 13 |
14 |
15 |
16 |
17 | {copyright} 18 |
19 |
20 |
21 |
22 | Version {version} 23 |
24 |
25 |
26 | ); 27 | }; 28 | export default Footer; -------------------------------------------------------------------------------- /assets/css/paginate.css: -------------------------------------------------------------------------------- 1 | .page-item.active .page-link { 2 | background: yellow; 3 | color: #001e00; 4 | border: 3px solid orange; 5 | font-weight: bolder; 6 | padding: 0.5em !important; 7 | } 8 | 9 | div.linkedPage { 10 | display: inline-block; 11 | float: left; 12 | margin-right: auto; 13 | } 14 | 15 | div.linkedPage label { 16 | display: inline-block; 17 | float: left; 18 | } 19 | 20 | .linkedPage input { 21 | max-width: 7em; 22 | background-color: #001e00; 23 | color: yellow; 24 | font-weight: bolder; 25 | border: 3px solid orange; 26 | -webkit-border-radius: 3px; 27 | -moz-border-radius: 3px; 28 | border-radius: 3px; 29 | margin: 0 0.5em; 30 | } 31 | 32 | .page-item .page-link { 33 | background: #001e00; 34 | color: yellow; 35 | border: 2px solid darkorange; 36 | font-weight: bold; 37 | padding: 1em !important; 38 | } 39 | 40 | ul.pagination, li.page-item { 41 | display: inline-flex; 42 | } 43 | 44 | div.pager { 45 | text-align: right; 46 | } 47 | 48 | span.splitter { 49 | margin: 0 0.5em; 50 | } 51 | 52 | input.page-input { 53 | max-width: 3em; 54 | } -------------------------------------------------------------------------------- /src/data-table-caption.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import React from 'react' 3 | 4 | const DataTableCaption = ({ stockRecord, accountMode, accountModes, _formatUTCDateTime } = {}) => { 5 | if (accountMode === accountModes.WAREHOUSE) { 6 | return ( 7 | {process.env.REACT_APP_SHORT_ORG_NAME} Warehouse Stock Data 8 | {stockRecord.meta.datetime_of_request ? 9 | `[Request returned: 10 | ${_formatUTCDateTime({ 11 | dateTime: stockRecord.meta.datetime_of_request 12 | })}]` : ''} 13 | 14 | ) 15 | } else if (accountMode === accountModes.STORE) { 16 | return ( 17 | {process.env.REACT_APP_SHORT_ORG_NAME} Account Stock Data 18 | {stockRecord.meta.datetime_of_request ? 19 | `[Request returned: 20 | ${_formatUTCDateTime({ 21 | dateTime: stockRecord.meta.datetime_of_request 22 | })}]` : ''} 23 | 24 | ) 25 | } 26 | } 27 | export default DataTableCaption; -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 22 | Simple Stock Management 23 | 24 | 25 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/css/paginate.css: -------------------------------------------------------------------------------- 1 | .page-item.active .page-link { 2 | background: yellow; 3 | color: #001e00; 4 | border: 3px solid orange; 5 | font-weight: bolder; 6 | padding: 0.5em !important; 7 | } 8 | 9 | div.linkedPage { 10 | display: inline-block; 11 | float: left; 12 | margin-right: auto; 13 | } 14 | 15 | div.linkedPage label { 16 | display: inline-block; 17 | float: left; 18 | } 19 | 20 | .linkedPage input { 21 | max-width: 7em; 22 | background-color: #001e00; 23 | color: yellow; 24 | font-weight: bolder; 25 | border: 3px solid orange; 26 | -webkit-border-radius: 3px; 27 | -moz-border-radius: 3px; 28 | border-radius: 3px; 29 | margin: 0 0.5em; 30 | } 31 | 32 | .page-item .page-link { 33 | background: #001e00; 34 | color: yellow; 35 | border: 2px solid darkorange; 36 | font-weight: bold; 37 | padding: 1em !important; 38 | z-index: 0!important; 39 | } 40 | 41 | ul.pagination, li.page-item { 42 | display: inline-flex; 43 | } 44 | 45 | div.pager { 46 | text-align: right; 47 | } 48 | 49 | span.splitter { 50 | margin: 0 0.5em; 51 | } 52 | 53 | input.page-input { 54 | max-width: 4em; 55 | background-color: darkorange; 56 | color: #001e00; 57 | border: 2px solid darkgoldenrod !important; 58 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const BundleTracker = require('webpack-bundle-tracker'); 3 | const statsFileURL = './webpack-stats.json'; 4 | 5 | module.exports = { 6 | 7 | context: __dirname, 8 | 9 | mode: 'development', 10 | 11 | devtool: 'inline-source-map', 12 | 13 | entry: `./assets/js/index.jsx`, 14 | 15 | output: { 16 | path: path.resolve(`./assets/bundles/`), 17 | filename: "[name]-[hash].js" 18 | }, 19 | 20 | resolve: { 21 | extensions: [".jsx", ".js"] 22 | }, 23 | 24 | plugins: [ 25 | new BundleTracker({filename: statsFileURL}), 26 | ], 27 | 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.js$/, 32 | loader: 'babel-loader', 33 | exclude: /node_modules/ 34 | }, { 35 | test: /\.jsx$/, 36 | loader: 'babel-loader', 37 | exclude: /node_modules/ 38 | }, 39 | { 40 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 41 | loader: 'url-loader?limit=100000' 42 | }, 43 | { 44 | test: /\.css$/, 45 | loader: ['style-loader', 'css-loader'], 46 | //exclude: /node_modules/ 47 | } 48 | 49 | ] 50 | } 51 | }; -------------------------------------------------------------------------------- /src/message.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./css/data-table.css"; 3 | 4 | class Message extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | // keep real-time props changes in component state 8 | this.state = { 9 | message: "" 10 | }; 11 | } 12 | 13 | componentWillUnmount() {} 14 | 15 | static getDerivedStateFromProps(nextProps) { 16 | return { 17 | message: nextProps.message, 18 | messageClass: nextProps.messageClass 19 | }; 20 | } 21 | 22 | renderMessage = () => { 23 | let message = this.state.message 24 | ? this.state.message 25 | : this.state.datetimeOfRequest 26 | ? `Stock data queried ${this.state.datetimeOfRequest}` 27 | : ""; 28 | let messageClass = this.state.messageClass 29 | ? this.state.messageClass 30 | : "alert alert-info"; 31 | return ( 32 |
36 |
37 | 38 |
{message}
39 |
40 |
41 | ); 42 | }; 43 | 44 | render() { 45 | return
{this.state.message ? this.renderMessage() : null}
; 46 | } 47 | } 48 | 49 | export default Message; 50 | -------------------------------------------------------------------------------- /src/css/stock-update-modal.css: -------------------------------------------------------------------------------- 1 | button.close-button { 2 | margin-left: auto; 3 | } 4 | 5 | .modal-button-cell { 6 | text-align: right; 7 | } 8 | 9 | label { 10 | margin: 0.5em; 11 | font-weight: bold; 12 | } 13 | 14 | form.transfer-form { 15 | background-color: yellow; 16 | color: #001e00; 17 | } 18 | 19 | form.transfer-form-danger { 20 | background-color: #530000; 21 | color: white; 22 | min-height: 350px; 23 | } 24 | 25 | button.delete-btn, button.cancel-btn { 26 | margin: 2pc; 27 | } 28 | 29 | button.delete-btn:hover, button.cancel-btn:hover { 30 | transform: scale(1.75); 31 | transition: all 100ms ease-in-out; 32 | } 33 | 34 | button.stockActionButton { 35 | margin-bottom: 1vmax; 36 | } 37 | 38 | button.stockActionButton:hover { 39 | transform: scale(1.25); 40 | transition: all 100ms ease-in-out; 41 | } 42 | 43 | input { 44 | border-style: none !important; 45 | } 46 | 47 | .transfer { 48 | vertical-align: top; 49 | } 50 | 51 | .xfer-button { 52 | margin: 1em 0 0 0; 53 | text-align: right; 54 | } 55 | 56 | .ReactModal__Overlay { 57 | opacity: 0; 58 | transition: opacity 100ms ease-in-out; 59 | z-index: 10; /* prevents buttons lingering after clicked */ 60 | } 61 | 62 | .ReactModal__Overlay--after-open { 63 | opacity: 1; 64 | /* overflow-y: scroll !important; */ 65 | } 66 | 67 | .ReactModal__Overlay--before-close { 68 | opacity: 0; 69 | } 70 | 71 | .ReactModal__Content { 72 | min-width: 35vw!important; 73 | } -------------------------------------------------------------------------------- /assets/css/data-table.css: -------------------------------------------------------------------------------- 1 | div.data-table { 2 | min-height: 768px; 3 | } 4 | 5 | table { 6 | background-color: #001e00 !important; 7 | font-weight: bold; 8 | color: yellow; 9 | } 10 | 11 | table.table-bordered td, table.table-bordered th { 12 | border: 1px solid darkorange !important; 13 | } 14 | 15 | thead th { 16 | padding: 1em !important; 17 | } 18 | 19 | thead th:hover { 20 | color: #001e00 !important; 21 | background-color: yellow !important; 22 | } 23 | 24 | td { 25 | color: yellow !important; 26 | padding: 0.5em !important; 27 | vertical-align: middle !important; 28 | } 29 | 30 | td.action-col, th.action-col { 31 | color: white !important; 32 | } 33 | 34 | table td.table-small-font { 35 | font-size: 0.8em; 36 | } 37 | 38 | caption { 39 | font-size: 0.8em; 40 | text-align: right; 41 | color: orange; 42 | } 43 | 44 | .Modal { 45 | position: absolute; 46 | top: 40px; 47 | left: 40px; 48 | right: 40px; 49 | bottom: 40px; 50 | background-color: papayawhip; 51 | } 52 | 53 | .Overlay { 54 | position: fixed; 55 | top: 0; 56 | left: 0; 57 | right: 0; 58 | bottom: 0; 59 | background-color: rebeccapurple; 60 | } 61 | 62 | button.table-btn { 63 | margin: 0.25em; 64 | } 65 | 66 | div.message-card { 67 | margin: 0 0 1em 0; 68 | text-align: center; 69 | background-color: #001e00; 70 | border: 1px solid yellow; 71 | -webkit-border-radius: 7px; 72 | -moz-border-radius: 7px; 73 | border-radius: 7px; 74 | } 75 | 76 | .alert { 77 | font-weight: bold; 78 | font-size: 0.8em 79 | } -------------------------------------------------------------------------------- /assets/js/message.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../css/data-table.css'; 3 | 4 | class Message extends React.Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | // keep real-time props changes in component state 9 | this.state = { 10 | message: null, 11 | }; 12 | } 13 | 14 | componentWillMount() { 15 | } 16 | 17 | componentWillUnmount() { 18 | } 19 | 20 | componentWillReceiveProps(nextProps) { 21 | // generate new table 22 | this.setState({ 23 | message: nextProps.message, 24 | messageClass: nextProps.messageClass, 25 | }); 26 | } 27 | 28 | renderMessage = () => { 29 | let message = this.state.message ? this.state.message : 30 | this.state.datetimeOfRequest ? `Stock data queried ${this.state.datetimeOfRequest}` : ''; 31 | let 32 | messageClass = this.state.messageClass ? this.state.messageClass : 'alert alert-info'; 33 | return ( 34 |
35 |
36 | 37 |
38 | {message} 39 |
40 |
41 |
42 | ) 43 | }; 44 | 45 | render() { 46 | return ( 47 |
48 | {this.state.message ? this.renderMessage() : null} 49 |
50 | ) 51 | } 52 | } 53 | 54 | export default Message; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Dan Bright, dan@uplandsdynamic.com", 3 | "license": "GPLv3", 4 | "name": "simple-stock-management-frontend", 5 | "version": "4.1.8.2", 6 | "private": true, 7 | "description": "React frontend for Simple Stock Management application", 8 | "main": "index.js", 9 | "dependencies": { 10 | "@csstools/normalize.css": "*", 11 | "@fortawesome/fontawesome-svg-core": "*", 12 | "@fortawesome/free-brands-svg-icons": "*", 13 | "@fortawesome/free-regular-svg-icons": "*", 14 | "@fortawesome/free-solid-svg-icons": "*", 15 | "@fortawesome/react-fontawesome": "*", 16 | "acorn": "*", 17 | "axios": "*", 18 | "bootstrap": "*", 19 | "env-cmd": "*", 20 | "jquery": "*", 21 | "js-cookie": "*", 22 | "lodash.clonedeep": "*", 23 | "moment": "*", 24 | "moment-timezone": "*", 25 | "react": "*", 26 | "react-app-polyfill": "*", 27 | "react-dom": "*", 28 | "react-modal": "*", 29 | "react-router-dom": "*", 30 | "react-scripts": "*" 31 | }, 32 | "scripts": { 33 | "analyze": "source-map-explorer build/static/js/main.*", 34 | "start": "HTTPS=false PORT=3001 env-cmd -f .env.dev react-scripts start", 35 | "build:dev": "env-cmd -f .env.dev react-scripts build", 36 | "build:production": "env-cmd -f .env.production react-scripts build", 37 | "test": "react-scripts test", 38 | "test:once": "CI=true react-scripts test", 39 | "test:debug": "react-scripts --inspect-brk test --runInBand", 40 | "eject": "react-scripts eject", 41 | "preinstall": "npx npm-force-resolutions" 42 | }, 43 | "browserslist": [ 44 | ">0.2%", 45 | "not dead", 46 | "not ie <= 11", 47 | "not op_mini all" 48 | ], 49 | "resolutions": { 50 | "glob-parent": "*", 51 | "browserslist": "*", 52 | "immer": "*", 53 | "nth-check": "*", 54 | "ansi-html": "*", 55 | "postcss": "*" 56 | } 57 | } -------------------------------------------------------------------------------- /src/header.js: -------------------------------------------------------------------------------- 1 | import boxLogo from './img/box.svg' 2 | import './css/header.css'; 3 | import AppNameTitle from './app-name-title'; 4 | import LoginForm from './loginform'; 5 | import React from 'react'; 6 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 7 | 8 | const Header = ({ 9 | authMeta = {}, apiOptions = null, csrfToken = null, setMessage, getSessionStorage, setSessionStorage, 10 | deleteSessionStorage, setAuthentication, openTruck, accountModes, setAccountMode, accountMode 11 | } = {}) => { 12 | 13 | const storeAccount = accountMode === accountModes.WAREHOUSE 14 | 15 | const buttons = (
16 | 22 | 29 |
); 30 | return ( 31 | 32 |
33 | {authMeta.authenticated ? buttons : ''} 34 | 43 |
44 | {'Box 45 |
46 | 47 |

{storeAccount ? 'Warehouse' : "Store Account"}

48 |
49 |
50 |
51 |
52 | ); 53 | }; 54 | 55 | export default Header; -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import {configure} from "enzyme/build"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | import 'jest-enzyme'; 4 | 5 | const localStorageMock = { 6 | getItem: jest.fn(), 7 | setItem: jest.fn(), 8 | removeItem: jest.fn(), 9 | clear: jest.fn(), 10 | }; 11 | 12 | const sessionStorageMock = { 13 | getItem: jest.fn(), 14 | setItem: jest.fn(), 15 | removeItem: jest.fn(), 16 | clear: jest.fn(), 17 | }; 18 | 19 | const mockAPIOptions = { 20 | /* used to define available API options in the api-request component */ 21 | GET_STOCK: {name: 'get_stock', method: 'GET', desc: 'request to get stock data'}, 22 | PATCH_STOCK: {name: 'patch_stock', method: 'PATCH', desc: 'PATCH request to update stock data'}, 23 | ADD_STOCK: {name: 'add_stock', method: 'POST', desc: 'POST request to add stock data'}, 24 | DELETE_STOCK_LINE: {name: 'delete_stock_line', method: 'DELETE', desc: 'DELETE request to delete stock line'}, 25 | POST_AUTH: {name: 'post_auth', method: 'POST', desc: 'POST request to for authorization'}, 26 | PATCH_CHANGE_PW: {name: 'patch_change_pw', method: 'PATCH', desc: 'PATCH request to for changing password'}, 27 | }; 28 | 29 | const mockProps = { 30 | stockRecord: { 31 | meta: { 32 | page: 1, 33 | limit: 3, 34 | pagerMainSize: 5, 35 | pagerEndSize: 3, 36 | pageOrderBy: '', 37 | pageOrderDir: '', 38 | previous: null, 39 | next: null, 40 | cacheControl: 'no-cache', // no caching by default, so always returns fresh data 41 | search: '', 42 | newRecord: false, 43 | deleteRecord: false 44 | }, 45 | data: { 46 | results: [], 47 | updateData: { 48 | id: '', 49 | sku: '', 50 | desc: '', 51 | units_total: 0, 52 | unit_price: 0, 53 | units_to_transfer: 0, 54 | start_units_total: 0 55 | } 56 | } 57 | }, 58 | authMeta: { 59 | authenticated: false, 60 | userIsAdmin: false, 61 | }, 62 | updateModalOpen: false, 63 | apiMode: mockAPIOptions.GET_STOCK, // will be one of apiOptions when triggered 64 | message: null, 65 | messageClass: '', 66 | greeting: process.env.REACT_APP_GREETING, 67 | csrfToken: null 68 | }; 69 | 70 | 71 | global.localStorage = localStorageMock; 72 | global.sessionStorage = sessionStorageMock; 73 | global.mockProps = mockProps; 74 | global.mockAPIOptions = mockAPIOptions; 75 | 76 | // set enzyme adapter 77 | configure({adapter: new Adapter()}); -------------------------------------------------------------------------------- /src/css/data-table.css: -------------------------------------------------------------------------------- 1 | div.data-table { 2 | min-height: 768px; 3 | font-size: 1.7vm!important; 4 | } 5 | 6 | .table-control-btns .btn { 7 | margin-right: 0.1pc; 8 | } 9 | 10 | table { 11 | background-color: #001e00 !important; 12 | font-weight: bold; 13 | color: yellow; 14 | } 15 | 16 | table.table-bordered td, table.table-bordered th { 17 | border: 1px solid darkorange !important; 18 | font-size: 0.8pc; 19 | } 20 | 21 | thead th { 22 | padding: 1em !important; 23 | } 24 | 25 | td { 26 | color: yellow !important; 27 | padding: 0.5em !important; 28 | vertical-align: middle !important; 29 | } 30 | 31 | td.action-col, th.action-col { 32 | color: white !important; 33 | } 34 | 35 | tr { 36 | border: none; 37 | } 38 | 39 | tr.outOfStock td.unitsTotal { 40 | color: orangered !important; 41 | font-size: 0.7em; 42 | } 43 | 44 | tr.outOfStock td.sku { 45 | color: orangered !important; 46 | } 47 | 48 | table td.table-small-font { 49 | font-size: 0.8em; 50 | } 51 | 52 | table.stockTable thead th:hover { 53 | color: #001e00 !important; 54 | background-color: yellow !important; 55 | } 56 | 57 | table.stockTable-disabled, table.stockTable-disabled input { 58 | font-size: 0.8em; 59 | } 60 | 61 | table.stockTable-disabled input { 62 | background-color: lightslategrey !important; 63 | border: none; 64 | } 65 | 66 | table.transferTable td, table.transferTable { 67 | background-color: slateblue; 68 | font-size: 1.1em; 69 | } 70 | 71 | table.transferTable input { 72 | font-size: 1.1em; 73 | } 74 | 75 | caption { 76 | font-size: 0.8em; 77 | text-align: right; 78 | color: orange; 79 | } 80 | 81 | input.search { 82 | background-color: darkorange; 83 | color: #001e00 !important; 84 | font-weight: bold; 85 | } 86 | 87 | input.search::placeholder { 88 | color: #001e00 !important; 89 | font-weight: bold; 90 | } 91 | 92 | input.search { 93 | border: 2px solid darkgoldenrod !important; 94 | } 95 | 96 | .Modal { 97 | position: absolute; 98 | top: 40px; 99 | left: 40px; 100 | right: 40px; 101 | bottom: 40px; 102 | background-color: papayawhip; 103 | } 104 | 105 | .Overlay { 106 | position: fixed; 107 | top: 0; 108 | left: 0; 109 | right: 0; 110 | bottom: 0; 111 | background-color: rebeccapurple; 112 | } 113 | 114 | div.message-card { 115 | margin: 0 0 1em 0; 116 | text-align: center; 117 | background-color: #001e00; 118 | border: 1px solid yellow; 119 | -webkit-border-radius: 7px; 120 | -moz-border-radius: 7px; 121 | border-radius: 7px; 122 | } 123 | 124 | .alert { 125 | font-weight: bold; 126 | font-size: 0.8em 127 | } -------------------------------------------------------------------------------- /prod.webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const BundleTracker = require('webpack-bundle-tracker'); 3 | const statsFileURL = './webpack-stats.json'; 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 6 | const webpack = require('webpack'); 7 | 8 | module.exports = { 9 | 10 | context: __dirname, 11 | 12 | mode: 'production', 13 | 14 | devtool: "source-map", 15 | 16 | entry: { 17 | main: './assets/js/index.jsx', 18 | data: './assets/js/data-table.jsx', 19 | api: './assets/js/api-request.jsx', 20 | modal: './assets/js/stock-update-modal.jsx' 21 | }, 22 | 23 | output: { 24 | path: path.resolve(__dirname, 'assets/bundles/'), 25 | filename: '[name]-[hash].js', 26 | chunkFilename: '[name]-[hash].js' 27 | }, 28 | 29 | resolve: { 30 | extensions: [".jsx", ".js"] 31 | }, 32 | 33 | optimization: { 34 | namedModules: true, // NamedModulesPlugin() 35 | splitChunks: { // CommonsChunkPlugin() 36 | name: 'vendor', 37 | minChunks: 2 38 | }, 39 | noEmitOnErrors: true, // NoEmitOnErrorsPlugin 40 | concatenateModules: true //ModuleConcatenationPlugin 41 | }, 42 | 43 | plugins: [ 44 | new BundleTracker({filename: statsFileURL}), 45 | new MiniCssExtractPlugin({ 46 | // Options similar to the same options in webpackOptions.output 47 | // both options are optional 48 | filename: '[name]-[hash].css', 49 | chunkFilename: '[id].css' 50 | }), 51 | new BundleAnalyzerPlugin({'analyzerMode': 'disabled'}), // server|static|disabled 52 | new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) 53 | ], 54 | 55 | module: { 56 | rules: [ 57 | { 58 | test: /\.js$/, 59 | exclude: /node_modules/, 60 | use: { 61 | loader: 'babel-loader', 62 | options: {} 63 | } 64 | }, { 65 | test: /\.jsx$/, 66 | exclude: /node_modules/, 67 | use: { 68 | loader: 'babel-loader', 69 | options: {} 70 | } 71 | }, 72 | { 73 | test: /\.(png|woff|woff2|eot|ttf|svg)$/, 74 | use: { 75 | loader: 'url-loader?limit=100000' 76 | } 77 | }, 78 | { 79 | test: /\.css$/, 80 | use: 81 | [ 82 | { 83 | loader: MiniCssExtractPlugin.loader, 84 | options: {} 85 | }, 86 | 'css-loader' 87 | ] 88 | } 89 | 90 | ] 91 | } 92 | }; -------------------------------------------------------------------------------- /src/data-table-nav.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import Paginate from './paginate.js'; 4 | import React from 'react' 5 | 6 | const DataTableNav = ({ 7 | stockRecord = null, handleGetRecords, handleAddRecord, handleSearch, handleStockTake, authMeta = null, 8 | accountMode, accountModes, getStyles 9 | } = {}) => { 10 | const { userIsAdmin, authenticated } = authMeta; 11 | const addRecordButton = ( 12 | ); 15 | 16 | const stockTakeButton = ( 17 | 20 | ) 21 | if (stockRecord && authenticated) { 22 | return ( 23 | 24 |
25 |
26 |
27 |
28 | 36 |
37 |
38 |
39 |
40 |
41 | 46 | {userIsAdmin || accountMode === accountModes.STORE ? addRecordButton : ''} 47 | {accountMode === accountModes.STORE ? stockTakeButton : ''} 48 |
49 | 56 |
57 |
58 |
59 | ) 60 | } 61 | return null; 62 | }; 63 | 64 | export default DataTableNav; -------------------------------------------------------------------------------- /assets/js/env.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const _DEPLOYMENT_OPTIONS = { 4 | LOCAL: 'Local development server', 5 | STAGING: 'Staging server', 6 | PROD: 'Production server' 7 | }; 8 | const _DEPLOYMENT = _DEPLOYMENT_OPTIONS.LOCAL; // LOCAL, STAGING or PROD 9 | let Env = null; 10 | switch (_DEPLOYMENT) { 11 | case _DEPLOYMENT_OPTIONS.LOCAL: 12 | Env = () => { 13 | return { 14 | /* LOCAL DEV */ 15 | apiRoot: 'http://localhost:3000/api', 16 | apiDataRoot: 'http://localhost:3000/api/v1', 17 | appDetails: { 18 | organisationName: 'Aninstance Consultancy', 19 | shortOrgName: 'Aninstance Consultancy', 20 | copyright: 21 | (

Application developed by Aninstance Consultancy. Tap here for support.

23 |
), 24 | footerText: `This is a demo instance of the Simple Stock Management application.`, 25 | greeting: (Simple Stock Management), 26 | version: (
Version: 1.1.0
) 27 | } 28 | }; 29 | }; 30 | break; 31 | case _DEPLOYMENT_OPTIONS.STAGING: 32 | Env = () => { 33 | return { 34 | // /* STAGING */ 35 | apiRoot: 'https://stockmanagement.staging.aninstance.com/api', 36 | apiDataRoot: 'https://stockmanagement.staging.aninstance.com/api/v1', 37 | appDetails: { 38 | organisationName: 'Aninstance Consultancy', 39 | shortOrgName: 'Aninstance Consultancy', 40 | copyright: 41 | (

Application developed by Aninstance Consultancy. Tap here for support.

43 |
), 44 | footerText: `This is a demo instance of the Simple Stock Management application.`, 45 | greeting: (Simple Stock Management), 46 | version: (
Version: 1.1.0
) 47 | } 48 | }; 49 | }; 50 | break; 51 | case _DEPLOYMENT_OPTIONS.PROD: 52 | Env = () => { 53 | return { 54 | /* PRODUCTION */ 55 | apiRoot: 'https://stockmanagement.brentlodge.org/api', 56 | apiDataRoot: 'https://stockmanagement.brentlodge.org/api/v1', 57 | appDetails: { 58 | organisationName: 'Brent Lodge Bird & Wildlife Trust', 59 | shortOrgName: 'Brent Lodge', 60 | copyright: 61 | (

Application developed by Aninstance Consultancy. Tap here for support.

63 |
), 64 | footerText: `Brent Lodge Bird & Wildlife Trust. Registered Charity 276179.`, 65 | greeting: (Simple Stock Management), 66 | version: (
Version: 1.1.0
) 67 | } 68 | }; 69 | }; 70 | break; 71 | } 72 | 73 | export default Env; -------------------------------------------------------------------------------- /src/data-table-head.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import React from 'react' 3 | 4 | const DataTableHead = ({ handleColumnOrderChange, stockRecord, accountMode, accountModes } = {}) => { 5 | if (accountMode === accountModes.STORE) // if store account 6 | return ( 7 | 8 | handleColumnOrderChange({ stockRecord, newOrder: 'sku' })}> 10 | SKU 11 | 12 | handleColumnOrderChange({ stockRecord, newOrder: 'desc' })}> 14 | Description 15 | 16 | handleColumnOrderChange({ stockRecord, newOrder: 'units_total' })}> 18 | Units In Stock 19 | 20 | handleColumnOrderChange({ stockRecord, newOrder: 'shrinkage' })}> 22 | Shrinkage Since Stock Take 23 | 24 | handleColumnOrderChange({ stockRecord, newOrder: 'sold_units' })}> 26 | Sold Since Stock Take 27 | 28 | handleColumnOrderChange({ stockRecord, newOrder: 'xfer_price' })}> 30 | Xfer Price 31 | 32 | handleColumnOrderChange({ stockRecord, newOrder: 'selling_price' })}> 34 | Current Selling Price 35 | 36 | handleColumnOrderChange({ 37 | stockRecord, 38 | newOrder: 'record_updated' 39 | })}> 40 | Record Updated 41 | 42 | 43 | Action 44 | 45 | 46 | ) 47 | else { // warehouse 48 | return ( 49 | 50 | handleColumnOrderChange({ stockRecord, newOrder: 'sku' })}> 52 | SKU 53 | 54 | handleColumnOrderChange({ stockRecord, newOrder: 'desc' })}> 56 | Description 57 | 58 | handleColumnOrderChange({ stockRecord, newOrder: 'units_total' })}> 60 | Units 61 | 62 | handleColumnOrderChange({ stockRecord, newOrder: 'unit_price' })}> 64 | Unit Price 65 | 66 | handleColumnOrderChange({ 67 | stockRecord, 68 | newOrder: 'record_updated' 69 | })}> 70 | Record Updated 71 | 72 | 73 | Action 74 | 75 | 76 | ) 77 | } 78 | } 79 | 80 | export default DataTableHead; -------------------------------------------------------------------------------- /src/img/box.svg: -------------------------------------------------------------------------------- 1 | 8 | 9 | 18 | 19 | 20 | 21 | 30 | 31 | 32 | 33 | 42 | 43 | 44 | 45 | 46 | 52 | 58 | 64 | 70 | 76 | 82 | 91 | 97 | 105 | 111 | -------------------------------------------------------------------------------- /src/truck-table.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import React from 'react'; 4 | 5 | const TruckTable = ({ truck, changeUnits }) => { 6 | if (truck.length > 0) { 7 | const tableData = truck.map((consignment, index) => { 8 | const cargo = consignment.cargo; 9 | const editButtonClasses = ['btn', 'btn-warning']; 10 | return ( 11 | 12 | 13 | {cargo.sku} 14 | 15 | 16 | {cargo.desc} 17 | 18 | 19 | {cargo.units_to_transfer} 20 | 21 | 22 |
23 | 28 | 33 | 38 |
39 | 40 | 41 | ); 42 | }); 43 | return ( 44 | 45 |
46 |
47 |
48 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {tableData} 61 | 62 |
Truck
SKUDescriptionUnits to TransferAction
63 |
64 |
65 |
66 |
67 | ); 68 | } 69 | return ( 70 | 71 |
72 |
73 |
74 |
Truck is empty
75 |
76 |
77 |
78 |
79 | ) 80 | }; 81 | export default TruckTable; -------------------------------------------------------------------------------- /assets/css/vendor/bootstrap-4.0.0/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.0.0 (https://getbootstrap.com) 3 | * Copyright 2011-2018 The Bootstrap Authors 4 | * Copyright 2011-2018 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /src/css/vendor/bootstrap-4.0.0/bootstrap-reboot.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.0.0 (https://getbootstrap.com) 3 | * Copyright 2011-2018 The Bootstrap Authors 4 | * Copyright 2011-2018 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important} 8 | /*# sourceMappingURL=bootstrap-reboot.min.css.map */ -------------------------------------------------------------------------------- /src/stock-update-delete.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import React from 'react' 3 | 4 | const StockUpdateDelete = ({ stockRecord = null, authMeta = null, handleCloseModal, handleRecordUpdate, deleteRecord = false, accountModes, accountMode } = {}) => { 5 | const { desc, sku, unit_price, units_total, selling_price } = stockRecord.data.updateData; 6 | const userIsAdmin = authMeta.userIsAdmin; 7 | const storeAccount = accountMode === accountModes.STORE; 8 | if (deleteRecord && !storeAccount) { 9 | return ( 10 | 11 |
12 |
13 |
14 |
15 |

Delete "{desc}"?

16 |
    17 |
  • Description: {desc}
  • 18 |
  • SKU: {sku}
  • 19 |
  • Current Unit Price: {unit_price}
  • 20 |
  • Units In Stock: {units_total}
  • 21 |
22 | 23 |
24 |
25 | 32 | 39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | ) 47 | } else if (deleteRecord && storeAccount) { 48 | return ( 49 | 50 |
51 |
52 |
53 |
54 |

Delete "{desc}"?

55 |
    56 |
  • Description: {desc}
  • 57 |
  • SKU: {sku}
  • 58 |
  • Current Selling Price: {selling_price}
  • 59 |
  • Units In Stock: {units_total}
  • 60 |
61 | 62 |
63 |
64 | 71 | 78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | ) 86 | } 87 | return null; 88 | }; 89 | export default StockUpdateDelete; -------------------------------------------------------------------------------- /assets/css/vendor/bootstrap-4.0.0/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.0.0 (https://getbootstrap.com) 3 | * Copyright 2011-2018 The Bootstrap Authors 4 | * Copyright 2011-2018 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-family: sans-serif; 16 | line-height: 1.15; 17 | -webkit-text-size-adjust: 100%; 18 | -ms-text-size-adjust: 100%; 19 | -ms-overflow-style: scrollbar; 20 | -webkit-tap-highlight-color: transparent; 21 | } 22 | 23 | @-ms-viewport { 24 | width: device-width; 25 | } 26 | 27 | article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section { 28 | display: block; 29 | } 30 | 31 | body { 32 | margin: 0; 33 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 34 | font-size: 1rem; 35 | font-weight: 400; 36 | line-height: 1.5; 37 | color: #212529; 38 | text-align: left; 39 | background-color: #fff; 40 | } 41 | 42 | [tabindex="-1"]:focus { 43 | outline: 0 !important; 44 | } 45 | 46 | hr { 47 | box-sizing: content-box; 48 | height: 0; 49 | overflow: visible; 50 | } 51 | 52 | h1, h2, h3, h4, h5, h6 { 53 | margin-top: 0; 54 | margin-bottom: 0.5rem; 55 | } 56 | 57 | p { 58 | margin-top: 0; 59 | margin-bottom: 1rem; 60 | } 61 | 62 | abbr[title], 63 | abbr[data-original-title] { 64 | text-decoration: underline; 65 | -webkit-text-decoration: underline dotted; 66 | text-decoration: underline dotted; 67 | cursor: help; 68 | border-bottom: 0; 69 | } 70 | 71 | address { 72 | margin-bottom: 1rem; 73 | font-style: normal; 74 | line-height: inherit; 75 | } 76 | 77 | ol, 78 | ul, 79 | dl { 80 | margin-top: 0; 81 | margin-bottom: 1rem; 82 | } 83 | 84 | ol ol, 85 | ul ul, 86 | ol ul, 87 | ul ol { 88 | margin-bottom: 0; 89 | } 90 | 91 | dt { 92 | font-weight: 700; 93 | } 94 | 95 | dd { 96 | margin-bottom: .5rem; 97 | margin-left: 0; 98 | } 99 | 100 | blockquote { 101 | margin: 0 0 1rem; 102 | } 103 | 104 | dfn { 105 | font-style: italic; 106 | } 107 | 108 | b, 109 | strong { 110 | font-weight: bolder; 111 | } 112 | 113 | small { 114 | font-size: 80%; 115 | } 116 | 117 | sub, 118 | sup { 119 | position: relative; 120 | font-size: 75%; 121 | line-height: 0; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -.25em; 127 | } 128 | 129 | sup { 130 | top: -.5em; 131 | } 132 | 133 | a { 134 | color: #007bff; 135 | text-decoration: none; 136 | background-color: transparent; 137 | -webkit-text-decoration-skip: objects; 138 | } 139 | 140 | a:hover { 141 | color: #0056b3; 142 | text-decoration: underline; 143 | } 144 | 145 | a:not([href]):not([tabindex]) { 146 | color: inherit; 147 | text-decoration: none; 148 | } 149 | 150 | a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus { 151 | color: inherit; 152 | text-decoration: none; 153 | } 154 | 155 | a:not([href]):not([tabindex]):focus { 156 | outline: 0; 157 | } 158 | 159 | pre, 160 | code, 161 | kbd, 162 | samp { 163 | font-family: monospace, monospace; 164 | font-size: 1em; 165 | } 166 | 167 | pre { 168 | margin-top: 0; 169 | margin-bottom: 1rem; 170 | overflow: auto; 171 | -ms-overflow-style: scrollbar; 172 | } 173 | 174 | figure { 175 | margin: 0 0 1rem; 176 | } 177 | 178 | img { 179 | vertical-align: middle; 180 | border-style: none; 181 | } 182 | 183 | svg:not(:root) { 184 | overflow: hidden; 185 | } 186 | 187 | table { 188 | border-collapse: collapse; 189 | } 190 | 191 | caption { 192 | padding-top: 0.75rem; 193 | padding-bottom: 0.75rem; 194 | color: #6c757d; 195 | text-align: left; 196 | caption-side: bottom; 197 | } 198 | 199 | th { 200 | text-align: inherit; 201 | } 202 | 203 | label { 204 | display: inline-block; 205 | margin-bottom: .5rem; 206 | } 207 | 208 | button { 209 | border-radius: 0; 210 | } 211 | 212 | button:focus { 213 | outline: 1px dotted; 214 | outline: 5px auto -webkit-focus-ring-color; 215 | } 216 | 217 | input, 218 | button, 219 | select, 220 | optgroup, 221 | textarea { 222 | margin: 0; 223 | font-family: inherit; 224 | font-size: inherit; 225 | line-height: inherit; 226 | } 227 | 228 | button, 229 | input { 230 | overflow: visible; 231 | } 232 | 233 | button, 234 | select { 235 | text-transform: none; 236 | } 237 | 238 | button, 239 | html [type="button"], 240 | [type="reset"], 241 | [type="submit"] { 242 | -webkit-appearance: button; 243 | } 244 | 245 | button::-moz-focus-inner, 246 | [type="button"]::-moz-focus-inner, 247 | [type="reset"]::-moz-focus-inner, 248 | [type="submit"]::-moz-focus-inner { 249 | padding: 0; 250 | border-style: none; 251 | } 252 | 253 | input[type="radio"], 254 | input[type="checkbox"] { 255 | box-sizing: border-box; 256 | padding: 0; 257 | } 258 | 259 | input[type="date"], 260 | input[type="time"], 261 | input[type="datetime-local"], 262 | input[type="month"] { 263 | -webkit-appearance: listbox; 264 | } 265 | 266 | textarea { 267 | overflow: auto; 268 | resize: vertical; 269 | } 270 | 271 | fieldset { 272 | min-width: 0; 273 | padding: 0; 274 | margin: 0; 275 | border: 0; 276 | } 277 | 278 | legend { 279 | display: block; 280 | width: 100%; 281 | max-width: 100%; 282 | padding: 0; 283 | margin-bottom: .5rem; 284 | font-size: 1.5rem; 285 | line-height: inherit; 286 | color: inherit; 287 | white-space: normal; 288 | } 289 | 290 | progress { 291 | vertical-align: baseline; 292 | } 293 | 294 | [type="number"]::-webkit-inner-spin-button, 295 | [type="number"]::-webkit-outer-spin-button { 296 | height: auto; 297 | } 298 | 299 | [type="search"] { 300 | outline-offset: -2px; 301 | -webkit-appearance: none; 302 | } 303 | 304 | [type="search"]::-webkit-search-cancel-button, 305 | [type="search"]::-webkit-search-decoration { 306 | -webkit-appearance: none; 307 | } 308 | 309 | ::-webkit-file-upload-button { 310 | font: inherit; 311 | -webkit-appearance: button; 312 | } 313 | 314 | output { 315 | display: inline-block; 316 | } 317 | 318 | summary { 319 | display: list-item; 320 | cursor: pointer; 321 | } 322 | 323 | template { 324 | display: none; 325 | } 326 | 327 | [hidden] { 328 | display: none !important; 329 | } 330 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /src/css/vendor/bootstrap-4.0.0/bootstrap-reboot.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap Reboot v4.0.0 (https://getbootstrap.com) 3 | * Copyright 2011-2018 The Bootstrap Authors 4 | * Copyright 2011-2018 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | * Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md) 7 | */ 8 | *, 9 | *::before, 10 | *::after { 11 | box-sizing: border-box; 12 | } 13 | 14 | html { 15 | font-family: sans-serif; 16 | line-height: 1.15; 17 | -webkit-text-size-adjust: 100%; 18 | -ms-text-size-adjust: 100%; 19 | -ms-overflow-style: scrollbar; 20 | -webkit-tap-highlight-color: transparent; 21 | } 22 | 23 | @-ms-viewport { 24 | width: device-width; 25 | } 26 | 27 | article, aside, dialog, figcaption, figure, footer, header, hgroup, main, nav, section { 28 | display: block; 29 | } 30 | 31 | body { 32 | margin: 0; 33 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 34 | font-size: 1rem; 35 | font-weight: 400; 36 | line-height: 1.5; 37 | color: #212529; 38 | text-align: left; 39 | background-color: #fff; 40 | } 41 | 42 | [tabindex="-1"]:focus { 43 | outline: 0 !important; 44 | } 45 | 46 | hr { 47 | box-sizing: content-box; 48 | height: 0; 49 | overflow: visible; 50 | } 51 | 52 | h1, h2, h3, h4, h5, h6 { 53 | margin-top: 0; 54 | margin-bottom: 0.5rem; 55 | } 56 | 57 | p { 58 | margin-top: 0; 59 | margin-bottom: 1rem; 60 | } 61 | 62 | abbr[title], 63 | abbr[data-original-title] { 64 | text-decoration: underline; 65 | -webkit-text-decoration: underline dotted; 66 | text-decoration: underline dotted; 67 | cursor: help; 68 | border-bottom: 0; 69 | } 70 | 71 | address { 72 | margin-bottom: 1rem; 73 | font-style: normal; 74 | line-height: inherit; 75 | } 76 | 77 | ol, 78 | ul, 79 | dl { 80 | margin-top: 0; 81 | margin-bottom: 1rem; 82 | } 83 | 84 | ol ol, 85 | ul ul, 86 | ol ul, 87 | ul ol { 88 | margin-bottom: 0; 89 | } 90 | 91 | dt { 92 | font-weight: 700; 93 | } 94 | 95 | dd { 96 | margin-bottom: .5rem; 97 | margin-left: 0; 98 | } 99 | 100 | blockquote { 101 | margin: 0 0 1rem; 102 | } 103 | 104 | dfn { 105 | font-style: italic; 106 | } 107 | 108 | b, 109 | strong { 110 | font-weight: bolder; 111 | } 112 | 113 | small { 114 | font-size: 80%; 115 | } 116 | 117 | sub, 118 | sup { 119 | position: relative; 120 | font-size: 75%; 121 | line-height: 0; 122 | vertical-align: baseline; 123 | } 124 | 125 | sub { 126 | bottom: -.25em; 127 | } 128 | 129 | sup { 130 | top: -.5em; 131 | } 132 | 133 | a { 134 | color: #007bff; 135 | text-decoration: none; 136 | background-color: transparent; 137 | -webkit-text-decoration-skip: objects; 138 | } 139 | 140 | a:hover { 141 | color: #0056b3; 142 | text-decoration: underline; 143 | } 144 | 145 | a:not([href]):not([tabindex]) { 146 | color: inherit; 147 | text-decoration: none; 148 | } 149 | 150 | a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus { 151 | color: inherit; 152 | text-decoration: none; 153 | } 154 | 155 | a:not([href]):not([tabindex]):focus { 156 | outline: 0; 157 | } 158 | 159 | pre, 160 | code, 161 | kbd, 162 | samp { 163 | font-family: monospace, monospace; 164 | font-size: 1em; 165 | } 166 | 167 | pre { 168 | margin-top: 0; 169 | margin-bottom: 1rem; 170 | overflow: auto; 171 | -ms-overflow-style: scrollbar; 172 | } 173 | 174 | figure { 175 | margin: 0 0 1rem; 176 | } 177 | 178 | img { 179 | vertical-align: middle; 180 | border-style: none; 181 | } 182 | 183 | svg:not(:root) { 184 | overflow: hidden; 185 | } 186 | 187 | table { 188 | border-collapse: collapse; 189 | } 190 | 191 | caption { 192 | padding-top: 0.75rem; 193 | padding-bottom: 0.75rem; 194 | color: #6c757d; 195 | text-align: left; 196 | caption-side: bottom; 197 | } 198 | 199 | th { 200 | text-align: inherit; 201 | } 202 | 203 | label { 204 | display: inline-block; 205 | margin-bottom: .5rem; 206 | } 207 | 208 | button { 209 | border-radius: 0; 210 | } 211 | 212 | button:focus { 213 | outline: 1px dotted; 214 | outline: 5px auto -webkit-focus-ring-color; 215 | } 216 | 217 | input, 218 | button, 219 | select, 220 | optgroup, 221 | textarea { 222 | margin: 0; 223 | font-family: inherit; 224 | font-size: inherit; 225 | line-height: inherit; 226 | } 227 | 228 | button, 229 | input { 230 | overflow: visible; 231 | } 232 | 233 | button, 234 | select { 235 | text-transform: none; 236 | } 237 | 238 | button, 239 | html [type="button"], 240 | [type="reset"], 241 | [type="submit"] { 242 | -webkit-appearance: button; 243 | } 244 | 245 | button::-moz-focus-inner, 246 | [type="button"]::-moz-focus-inner, 247 | [type="reset"]::-moz-focus-inner, 248 | [type="submit"]::-moz-focus-inner { 249 | padding: 0; 250 | border-style: none; 251 | } 252 | 253 | input[type="radio"], 254 | input[type="checkbox"] { 255 | box-sizing: border-box; 256 | padding: 0; 257 | } 258 | 259 | input[type="date"], 260 | input[type="time"], 261 | input[type="datetime-local"], 262 | input[type="month"] { 263 | -webkit-appearance: listbox; 264 | } 265 | 266 | textarea { 267 | overflow: auto; 268 | resize: vertical; 269 | } 270 | 271 | fieldset { 272 | min-width: 0; 273 | padding: 0; 274 | margin: 0; 275 | border: 0; 276 | } 277 | 278 | legend { 279 | display: block; 280 | width: 100%; 281 | max-width: 100%; 282 | padding: 0; 283 | margin-bottom: .5rem; 284 | font-size: 1.5rem; 285 | line-height: inherit; 286 | color: inherit; 287 | white-space: normal; 288 | } 289 | 290 | progress { 291 | vertical-align: baseline; 292 | } 293 | 294 | [type="number"]::-webkit-inner-spin-button, 295 | [type="number"]::-webkit-outer-spin-button { 296 | height: auto; 297 | } 298 | 299 | [type="search"] { 300 | outline-offset: -2px; 301 | -webkit-appearance: none; 302 | } 303 | 304 | [type="search"]::-webkit-search-cancel-button, 305 | [type="search"]::-webkit-search-decoration { 306 | -webkit-appearance: none; 307 | } 308 | 309 | ::-webkit-file-upload-button { 310 | font: inherit; 311 | -webkit-appearance: button; 312 | } 313 | 314 | output { 315 | display: inline-block; 316 | } 317 | 318 | summary { 319 | display: list-item; 320 | cursor: pointer; 321 | } 322 | 323 | template { 324 | display: none; 325 | } 326 | 327 | [hidden] { 328 | display: none !important; 329 | } 330 | /*# sourceMappingURL=bootstrap-reboot.css.map */ -------------------------------------------------------------------------------- /src/data-table.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import './css/data-table.css'; 3 | import 'bootstrap/dist/css/bootstrap.css'; 4 | import 'bootstrap/dist/js/bootstrap.js'; 5 | import moment from 'moment'; 6 | import 'moment/locale/en-gb.js'; 7 | import 'moment-timezone'; 8 | import DataTableNav from "./data-table-nav"; 9 | import DataTableData from "./data-table-data"; 10 | import DataTableHead from "./data-table-head"; 11 | import DataTableCaption from './data-table-caption'; 12 | 13 | const DataTable = ({ stockRecord = {}, setMessage, openStockUpdateModalHandler, 14 | getRecordsHandler, stockTakeHandler, authMeta = {}, setStockRecordState, accountModes, 15 | accountMode, getStyles 16 | } = {}) => { 17 | 18 | const [confirmAction, setConfirmAction] = useState(false); 19 | const { authenticated } = authMeta; 20 | 21 | const _formatUTCDateTime = ({ dateTime = null } = {}) => { 22 | // takes datetime in UTC, formats and returns datetime in user's browser reported timezone 23 | return dateTime ? `${moment.utc(dateTime).local() 24 | .format('DD/MM/YYYY HH:mm:ss')} ${moment.tz(moment.tz.guess()).zoneAbbr()}` 25 | : null; 26 | }; 27 | 28 | const _handleColumnOrderChange = ({ stockRecord = {}, newOrder = {} } = {}) => { 29 | let { pageOrderDir, pageOrderBy } = stockRecord.meta; 30 | // set page order direction 31 | pageOrderDir = (!pageOrderBy || pageOrderDir === '-') ? '' : '-'; // *see note 1 32 | Object.assign(stockRecord.meta, { pageOrderBy: newOrder, pageOrderDir, page: 1 }); // maybe page:1 ? 33 | getRecordsHandler({ stockRecord }) 34 | }; 35 | 36 | const _handleAddRecord = ({ stockRecord = null } = {}) => { 37 | setMessage({ message: null }); // clear old messages 38 | openStockUpdateModalHandler({ stockRecord: stockRecord, newRecord: true }); // open modal 39 | }; 40 | 41 | const _handleEditRecord = ({ stockRecord = null, deleteRecord = false } = {}) => { 42 | setMessage({ message: null }); // clear old messages 43 | openStockUpdateModalHandler({ stockRecord, deleteRecord }); // open update modal 44 | }; 45 | 46 | const _handleStockTake = ({confirmed = false} = {}) => { 47 | setMessage({message: 'Initiating a stock take resets shrinkage & sales statistics ' + 48 | 'and generates a report. Are you sure you wish to continue?', 49 | messageClass: 'alert alert-warning' 50 | }) 51 | confirmed ? stockTakeHandler() : setConfirmAction(true); 52 | }; 53 | 54 | const _handleSearch = ({ stockRecord = {}, term = null } = {}) => { 55 | if (stockRecord) { 56 | Object.assign(stockRecord.meta, { 57 | pageOrderBy: 'desc', page: 1, 58 | search: _validateDesc(term) 59 | }); 60 | /* set new record state early, even though stockRecord again when API returns, 61 | to ensure search string change keeps pace with user typing speed 62 | */ 63 | setStockRecordState({ newStockRecord: stockRecord }); 64 | // get the matching records from the API 65 | getRecordsHandler({ stockRecord }); 66 | } 67 | }; 68 | 69 | const _validateDesc = (value) => { 70 | return (/^[a-zA-Z\d.\- ]*$/.test(value)) ? value : stockRecord.meta.search 71 | }; 72 | 73 | const _handleConfirm = ({ cancel = true } = {}) => { 74 | !cancel ? _handleStockTake({ confirmed: true }) : setMessage( 75 | { message: 'Stock take cancelled', messageClass: 'alert alert-info' } 76 | ); 77 | setConfirmAction(false); 78 | } 79 | 80 | const _stockTakeConfirmDialog = () => { 81 | return ( 82 | 83 | {authenticated ? 84 | 85 | 87 | 89 | : } 90 | 91 | ); 92 | }; 93 | 94 | return ( 95 |
96 | 107 |
108 |
109 |
110 | 111 | 117 | 118 | 124 | 125 | 126 | {!confirmAction ? : _stockTakeConfirmDialog()} 134 | 135 |
136 |
137 |
138 |
139 |
140 | ) 141 | }; 142 | 143 | export default DataTable; 144 | -------------------------------------------------------------------------------- /src/truck.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./css/stock-update-modal.css"; 3 | import Modal from "react-modal"; 4 | import TruckTable from "./truck-table"; 5 | import processRequest from "./api"; 6 | 7 | Modal.setAppElement(document.body); 8 | 9 | Modal.defaultStyles.overlay.backgroundColor = "cornflowerblue"; 10 | const REGULAR_STYLES = { 11 | content: { 12 | top: "50%", 13 | left: "50%", 14 | right: "auto", 15 | bottom: "auto", 16 | marginRight: "-50%", 17 | transform: "translate(-50%, -50%)", 18 | backgroundColor: "#001e00", 19 | color: "yellow", 20 | border: "1px solid yellow", 21 | borderRadius: "7px 7px 7px 7px", 22 | boxShadow: "-7px -7px 17px 7px #001e00", 23 | maxWidth: "800px", 24 | maxHeight: "95%" 25 | }, 26 | overlay: { 27 | backgroundColor: "#2a3517" 28 | } 29 | }; 30 | 31 | // const DANGER_STYLES = { 32 | // content: { 33 | // top: '50%', 34 | // left: '50%', 35 | // right: 'auto', 36 | // bottom: 'auto', 37 | // marginRight: '-50%', 38 | // transform: 'translate(-50%, -50%)', 39 | // backgroundColor: 'darkred', 40 | // color: 'white', 41 | // border: '1px solid yellow', 42 | // borderRadius: '7px 7px 7px 7px', 43 | // boxShadow: '-7px -7px 17px 7px black', 44 | // maxWidth: '800px', 45 | // }, 46 | // overlay: { 47 | // backgroundColor: 'gold', 48 | // } 49 | // }; 50 | 51 | /* 52 | Note: Various ways to style the modal: 53 | - css stylesheets 54 | - inline (as above, set in state (modalStyles)) 55 | - default styles (also as above, as used for overlay background color). 56 | For more info. see: https://reactcommunity.org/react-modal/examples/css_classes.html 57 | */ 58 | 59 | class TruckModal extends React.Component { 60 | constructor(props) { 61 | super(props); 62 | this.state = { 63 | modalStyles: REGULAR_STYLES, 64 | modalIsOpen: this.props.openTruckModal, 65 | truck: this.props.truck 66 | }; 67 | // Remember! This binding is necessary to make `this` work in the callback 68 | this.handleAfterOpenModal = this.handleAfterOpenModal.bind(this); 69 | } 70 | 71 | componentWillUnmount() { 72 | console.log("Unmounting truck modal"); 73 | } 74 | 75 | static getDerivedStateFromProps(nextProps, prevState) { 76 | // set modal open/close state (source of truth in parent component - app.js) 77 | let newState = {}; 78 | if (nextProps.truck !== prevState.truck) { 79 | newState.truck = nextProps.truck; 80 | } 81 | return { ...newState, modalIsOpen: nextProps.openTruckModal }; 82 | } 83 | 84 | componentDidUpdate() {} 85 | 86 | componentDidMount() {} 87 | 88 | handleAfterOpenModal() { 89 | // this.subtitle.style.color = 'yellow' 90 | } 91 | 92 | dispatchTruck() { 93 | /* 94 | method to make API request to transfer stock (PATCH request). 95 | */ 96 | // copy truck obj to work on, by converting to string representation, then back (use JSON obj representation) 97 | let truck = JSON.parse(JSON.stringify(this.props.truck)); 98 | if (truck.length > 0) { 99 | try { 100 | /* if transferring, delete all superfluous fields from the query to prevent auth issues */ 101 | truck.forEach((consignment, index) => { 102 | let cargo = consignment.cargo; 103 | truck[index] = { 104 | id: cargo.id, 105 | units_to_transfer: cargo.units_to_transfer 106 | }; 107 | }); 108 | // hit the api 109 | const apiRequest = processRequest({ 110 | stockRecord: { records: truck }, 111 | apiMode: this.props.apiOptions.PATCH_STOCK 112 | }); 113 | if (apiRequest) { 114 | apiRequest 115 | .then(response => { 116 | if (response) { 117 | if (response.status === 200) { 118 | this.props.emptyTruck(); 119 | // update the main table with the new values 120 | this.props.openTruckModalHandler({ 121 | actionCancelled: false, 122 | returnedRecords: response.data 123 | }); 124 | } 125 | } 126 | }) 127 | .catch(error => { 128 | console.log(`API error: ${error}`); 129 | this.props.setMessage({ 130 | message: "Transfer failed! The API rejected the request.", 131 | messageClass: "alert alert-danger" 132 | }); 133 | this.props.openTruckModalHandler({ actionCancelled: false }); 134 | }); 135 | } 136 | } catch (err) { 137 | // allow fall through to return false by default 138 | console.log(`API error: ${err}`); 139 | this.props.openTruckModalHandler({ actionCancelled: false }); 140 | } 141 | } 142 | } 143 | 144 | render() { 145 | if (this.props.stockRecord) { 146 | const dispatchButtonClasses = [ 147 | "btn", 148 | "btn", 149 | "btn-warning", 150 | "m-2", 151 | "table-btn" 152 | ]; 153 | if (!this.state.truck.length > 0) { 154 | dispatchButtonClasses.push("d-none"); 155 | } 156 | return ( 157 | 165 |
166 |
167 |
168 |

(this.subtitle = subtitle)}>Truck

169 |
170 | 174 |
175 | 183 | 191 |
192 |
193 |
194 |
195 | ); 196 | } 197 | return null; 198 | } 199 | } 200 | 201 | export default TruckModal; 202 | -------------------------------------------------------------------------------- /src/data-table-data.js: -------------------------------------------------------------------------------- 1 | import './css/data-table.css'; 2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 3 | import React from 'react' 4 | 5 | const DataTableData = ({ stockRecord = {}, handleEditRecord, accountMode, accountModes, 6 | formatUTCDateTime, authMeta = {} } = {}) => { 7 | const { authenticated, userIsAdmin } = authMeta; 8 | const storeAccount = accountMode === accountModes.STORE 9 | if (!stockRecord || !authenticated || (stockRecord && stockRecord.data.results.length < 1)) { 10 | return ( 11 | 12 | 13 | 14 |
There are no records to display!
15 | 16 | 17 |
18 | ) 19 | } 20 | if (storeAccount) { 21 | return stockRecord.data.results.map((item, index) => { 22 | let { sku, desc, units_total, xfer_price, record_updated, shrinkage, sold_units, selling_price } = item; 23 | let rowClasses = [units_total <= 0 ? 'outOfStock' : '', 'd-flex', 'dataTableRows']; 24 | let editButtonClasses = ['table-btn', 'btn', 'btn-primary', 'w-100', 'mb-1']; 25 | return ( 26 | {/*{item.id}*/} 27 | {sku} 28 | {desc} 29 | {units_total > 0 ? units_total : 'Out of Stock'} 30 | {shrinkage} 31 | {sold_units} 32 | {xfer_price} 33 | {selling_price} 34 | 35 | {formatUTCDateTime({ dateTime: record_updated })} 36 | 37 | 53 | {userIsAdmin ? : ''} 60 | 61 | ) 62 | }); 63 | } 64 | else { 65 | return stockRecord.data.results.map((item, index) => { 66 | let { sku, desc, units_total, unit_price, record_updated } = item; 67 | let rowClasses = [units_total <= 0 ? 'outOfStock' : '', 'd-flex', 'dataTableRows']; 68 | let editButtonClasses = [units_total <= 0 && (!userIsAdmin && !storeAccount) ? 69 | 'disabled' : '', 'table-btn', 'btn', 'btn-primary', 'w-100', 'mb-1']; 70 | return ( 71 | {/*{item.id}*/} 72 | {sku} 73 | {desc} 74 | {units_total > 0 ? units_total : 'Out of Stock'} 75 | {unit_price} 76 | 77 | {formatUTCDateTime({ dateTime: record_updated })} 78 | 79 | 92 | {userIsAdmin ? : ''} 99 | 100 | ) 101 | }); 102 | } 103 | }; 104 | 105 | export default DataTableData; 106 | 107 | /* 108 | Note 1: Be sure to pass values (e.g. {...item}) rather than obj (e.g. {item}), 109 | otherwise the item obj (corresponding to the data results on the main table) will be updated with 110 | values input in the console, as data.updateData would essentially 111 | point to data.results, rather than being a separate, discrete object. 112 | */ -------------------------------------------------------------------------------- /assets/js/paginate.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import '../css/paginate.css' 3 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' 4 | 5 | class Paginate extends React.Component { 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = { 10 | records: {}, 11 | totalPages: 0, 12 | currentPage: 1 13 | } 14 | } 15 | 16 | componentWillMount() { 17 | this.setState({ 18 | records: this.props.records, 19 | totalPages: Math.ceil(this.props.records.data.data.count / this.props.records.meta.limit), 20 | currentPage: this.props.records.meta.page 21 | }); 22 | } 23 | 24 | componentWillUnmount() { 25 | } 26 | 27 | componentWillReceiveProps(nextProps) { 28 | let count = nextProps.records.data.data.count || 0; 29 | this.setState({ 30 | records: nextProps.records, 31 | totalPages: Math.ceil(count / nextProps.records.meta.limit), 32 | currentPage: nextProps.records.meta.page 33 | }); 34 | } 35 | 36 | validatePage(value) { 37 | // if input is a number or space (allowing for backspace), return value (if 0 change to 1), else current page 38 | return /^[\d\s]*$/.test(value) ? parseInt(value) === 0 ? 1 : value : this.state.currentPage; 39 | } 40 | 41 | switchPage = ({linkedPage = 0, dir = 'selected'} = {}) => { 42 | let page, url = null; 43 | switch (dir) { 44 | case 'selected': 45 | page = linkedPage; 46 | break; 47 | case 'previous': 48 | page = this.state.records.meta.page >= 1 ? this.state.records.meta.page - 1 : 1; 49 | url = this.state.records.data.data.previous; 50 | break; 51 | case 'next': 52 | page = this.state.totalPages > this.state.records.meta.page ? 53 | this.state.records.meta.page + 1 : this.state.records.meta.page; 54 | url = this.state.records.data.data.next; 55 | break; 56 | } 57 | this.props.handleGetRecords({stockRecord: {meta: {page: page, url: url, preserveOrder: true}}}) 58 | }; 59 | 60 | currentPage = () => { 61 | return ( 62 | 63 |
64 | 65 | e.keyCode === 8 ? this.setState({currentPage: ''}) : null} 66 | onChange={(e) => { 67 | this.switchPage({ 68 | linkedPage: parseInt(e.target.value) > 0 ? parseInt(e.target.value) 69 | <= this.state.totalPages ? parseInt(e.target.value) : this.state.totalPages : 1, 70 | dir: 'selected' 71 | }); 72 | }} value={this.state.currentPage} type={'text'} 73 | className={'form-control input-sm page-input'}/> 74 |
75 |
76 | ); 77 | }; 78 | 79 | prevSection = () => { 80 | return ( 81 | 82 |
  • this.switchPage({dir: 'previous'})} className="page-link"> 85 | Previous
  • 86 |
    87 | ) 88 | }; 89 | 90 | nextSection = () => { 91 | return ( 92 | 93 |
  • this.switchPage({dir: 'next'})} href="#" className="page-link"> 96 | Next
  • 97 |
    98 | ) 99 | }; 100 | 101 | mainSection = () => { 102 | let pageItemClass; 103 | let pagerMainSize = this.state.records.meta.pagerMainSize; 104 | let linkedPage = this.state.records.meta.page; 105 | let totalPages = isNaN(this.state.totalPages) ? 0 : this.state.totalPages; 106 | return ( 107 | 108 | {[...Array(totalPages)].map((o, pageIndex) => { 109 | /* if selected (displayed) page is this list item, add 'active' to class to colour it 110 | and remove elements over the max size of displayed list */ 111 | pageIndex + 1 > pagerMainSize ? pageItemClass = 'd-none' : 112 | linkedPage === (pageIndex + 1) ? pageItemClass = 'active page-item' : 113 | pageItemClass = 'page-item'; 114 | return (
  • 115 | this.switchPage({linkedPage: pageIndex + 1, dir: 'selected'})} 116 | className="page-link"> 117 | {pageIndex + 1} 118 | 119 |
  • ); 120 | })} 121 |
    122 | ); 123 | }; 124 | 125 | endSection = () => { 126 | let pagerMainSize = this.state.records.meta.pagerMainSize; 127 | let linkedPage = this.state.records.meta.page; 128 | let totalPages = this.state.totalPages; 129 | let numEndEle = this.state.records.meta.pagerEndSize; 130 | let iterArray = Array.apply(null, {length: numEndEle}); // create array as basis for map in frag to iterate 131 | return ( 132 | 133 | {iterArray.map((o, c) => { 134 | let p = (iterArray.length - (c + 1)); // flip count order 135 | let page = (totalPages - p) || 0; 136 | return ( 137 |
  • pagerMainSize ? linkedPage === page ? 'active page-item' : 'page-item' : 'd-none'}> 139 | this.switchPage({linkedPage: page, dir: 'selected'})} href="#" 140 | className="page-link">{page}
  • ) 141 | })} 142 |
    143 | ); 144 | }; 145 | 146 | render() { 147 | return (
    148 | {this.currentPage()} 149 | 157 |
    158 | ); 159 | } 160 | } 161 | 162 | export default Paginate; -------------------------------------------------------------------------------- /assets/js/loginform.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../css/loginform.css'; 3 | import '../css/vendor/bootstrap-4.0.0/bootstrap.min.css'; 4 | import './vendor/bootstrap-4.0.0/bootstrap.min.js' 5 | import Greeting from './greeting'; 6 | 7 | class LoginForm extends React.Component { 8 | 9 | constructor(props) { 10 | super(props); 11 | // Remember! This binding is necessary to make `this` work in the callback 12 | this.loginHandler = this.loginHandler.bind(this); 13 | this.state = { 14 | formToDisplay: null, 15 | authenticated: false, 16 | password: '', 17 | username: '', 18 | oldPassword: '', 19 | newPassword: '' 20 | }; 21 | } 22 | 23 | componentWillMount() { 24 | this.setState({authenticated: this.props.authMeta.authenticated}, this.setFormToDisplay( 25 | {auth: this.props.authMeta.authenticated} 26 | )) 27 | } 28 | 29 | componentWillUnmount() { 30 | 31 | } 32 | 33 | componentWillReceiveProps(nextProps) { 34 | if (nextProps.authMeta.authenticated !== this.state.authenticated) { 35 | this.setState({authenticated: nextProps.authMeta.authenticated}, this.setFormToDisplay( 36 | {auth: nextProps.authMeta.authenticated} 37 | )) 38 | } 39 | } 40 | 41 | 42 | setFormToDisplay({form = null, auth = false} = {}) { 43 | if (auth) { 44 | this.setState({formToDisplay: form ? form : 'logout'}) 45 | } else { 46 | this.setState({formToDisplay: 'login'}) 47 | } 48 | } 49 | 50 | changePasswordFormDisplayHandler() { 51 | this.setFormToDisplay({form: 'changePassword', auth: this.state.authenticated}) 52 | } 53 | 54 | loginHandler() { 55 | let username = this.state.username; 56 | let password = this.state.password; 57 | this.setState({username: '', password: ''}); 58 | this.props.authenticate({ 59 | apiTrigger: this.props.API_OPTIONS.POST_AUTH, 60 | requestData: { 61 | data: { 62 | username: username, 63 | password: password 64 | }, 65 | } 66 | }); 67 | } 68 | 69 | changePasswordHandler() { 70 | let oldPassword = this.state.oldPassword; 71 | let newPassword = this.state.newPassword; 72 | this.setState({oldPassword: '', newPassword: ''}); 73 | this.props.authenticate({ 74 | apiTrigger: this.props.API_OPTIONS.PATCH_CHANGE_PW, 75 | requestData: { 76 | data: { 77 | old_password: oldPassword, 78 | new_password: newPassword, 79 | username: this.props.getSessionStorage('username') 80 | }, 81 | }, 82 | auth: this.state.authenticated 83 | }); 84 | } 85 | 86 | handleLogout() { 87 | this.props.deleteSessionStorage(['apiToken', 'username']); 88 | this.props.authenticate({auth: false}) 89 | } 90 | 91 | receivePassword(e) { 92 | this.setState({password: e.target.value}) 93 | } 94 | 95 | receiveUsername(e) { 96 | this.setState({username: e.target.value}) 97 | } 98 | 99 | receiveOldPassword(e) { 100 | this.setState({oldPassword: e.target.value}) 101 | } 102 | 103 | receiveNewPassword(e) { 104 | this.setState({newPassword: e.target.value}) 105 | } 106 | 107 | render() { 108 | let displayForm; 109 | switch (this.state.formToDisplay) { 110 | case 'login': 111 | displayForm = ( 112 |
    113 |
    114 |
    115 | this.receiveUsername(e)}/> 117 |
    118 |
    119 | this.receivePassword(e)}/> 122 |
    123 |
    124 | 130 |
    131 |
    132 |
    133 | ); 134 | break; 135 | case 'logout': 136 | displayForm = ( 137 |
    138 | Welcome {this.props.getSessionStorage('username')}! 139 | 142 | 144 |
    145 | ); 146 | break; 147 | case 'changePassword': 148 | displayForm = ( 149 |
    150 |
    151 |
    152 | 154 | this.receiveOldPassword(e)}/> 155 |
    156 |
    157 | 159 | this.receiveNewPassword(e)}/> 160 |
    161 |
    162 | 168 |
    169 |
    170 |
    171 | ); 172 | break; 173 | } 174 | return ( 175 |
    176 |
    177 |
    178 |
    {displayForm}
    179 |
    180 |
    181 | ) 182 | } 183 | } 184 | 185 | export default LoginForm; -------------------------------------------------------------------------------- /src/paginate.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./css/paginate.css"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | 5 | class Paginate extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.state = { 9 | stockRecord: props.stockRecord, 10 | totalPages: Math.ceil( 11 | props.stockRecord.data.count / this.props.stockRecord.meta.limit 12 | ), 13 | currentPage: props.stockRecord.meta.page 14 | }; 15 | } 16 | 17 | componentWillUnmount() {} 18 | 19 | static getDerivedStateFromProps(nextProps) { 20 | let count = nextProps.stockRecord.data.count || 0; 21 | return { 22 | stockRecord: nextProps.stockRecord, 23 | totalPages: Math.ceil(count / nextProps.stockRecord.meta.limit), 24 | currentPage: nextProps.stockRecord.meta.page 25 | }; 26 | } 27 | 28 | validatePage(value) { 29 | // if input is a number or space (allowing for backspace), return value (if 0 change to 1), else current page 30 | return /^[\d\s]*$/.test(value) 31 | ? parseInt(value) === 0 32 | ? 1 33 | : value 34 | : this.state.currentPage; 35 | } 36 | 37 | switchPage({ linkedPage = 0, dir = "selected" } = {}) { 38 | let { url, page, previous, next } = this.state.stockRecord.meta; 39 | switch (dir) { 40 | case "selected": 41 | page = linkedPage; 42 | url = null; // to be set in DataTable.handleGetRecords() 43 | break; 44 | case "previous": 45 | page = page >= 1 ? page - 1 : 1; 46 | url = previous ? previous : null; 47 | break; 48 | case "next": 49 | page = 50 | this.state.totalPages > this.state.stockRecord.meta.page 51 | ? this.state.stockRecord.meta.page + 1 52 | : this.state.stockRecord.meta.page; 53 | url = next ? next : null; 54 | break; 55 | default: 56 | page = linkedPage; 57 | break; 58 | } 59 | let newStockRecord = JSON.parse(JSON.stringify(this.state.stockRecord)); 60 | Object.assign(newStockRecord.meta, { 61 | page: page, 62 | limit: this.state.stockRecord.meta.limit 63 | }); 64 | this.props.handleGetRecords({ stockRecord: newStockRecord, url: url }); 65 | // locally set page state, to prevent delay when quick typing, pending overwrite by new source-of-truth prop 66 | this.setState({ currentPage: page }); 67 | } 68 | 69 | currentPage() { 70 | return ( 71 | 72 |
    73 | 74 | 76 | e.keyCode === 8 ? this.setState({ currentPage: "" }) : null 77 | } 78 | onChange={e => { 79 | this.switchPage({ 80 | linkedPage: 81 | parseInt(e.target.value) > 0 82 | ? parseInt(e.target.value) <= this.state.totalPages 83 | ? parseInt(e.target.value) 84 | : this.state.totalPages 85 | : 1, 86 | dir: "selected" 87 | }); 88 | }} 89 | value={this.state.currentPage} 90 | type={"text"} 91 | className={"form-control input-sm page-input"} 92 | /> 93 |
    94 |
    95 | ); 96 | } 97 | 98 | prevSection() { 99 | return ( 100 | 101 |
  • 102 | 111 |
  • 112 |
    113 | ); 114 | } 115 | 116 | nextSection() { 117 | return ( 118 | 119 |
  • 120 | 129 |
  • 130 |
    131 | ); 132 | } 133 | 134 | mainSection() { 135 | let pageItemClass; 136 | let pagerMainSize = this.state.stockRecord.meta.pagerMainSize; 137 | let linkedPage = this.state.stockRecord.meta.page; 138 | let totalPages = isNaN(this.state.totalPages) ? 0 : this.state.totalPages; 139 | let styles = this.props.getStyles(); 140 | return ( 141 | 142 | {[...Array(totalPages)].map((o, pageIndex) => { 143 | let currentPage = linkedPage === pageIndex + 1; 144 | /* if selected (displayed) page is this list item, add 'active' to class to colour it 145 | and remove elements over the max size of displayed list */ 146 | pageIndex + 1 > pagerMainSize 147 | ? (pageItemClass = "d-none") 148 | : currentPage 149 | ? (pageItemClass = "active page-item") 150 | : (pageItemClass = "page-item"); 151 | return ( 152 |
  • 153 | 165 |
  • 166 | ); 167 | })} 168 |
    169 | ); 170 | } 171 | 172 | endSection() { 173 | let pagerMainSize = this.state.stockRecord.meta.pagerMainSize; 174 | let linkedPage = this.state.stockRecord.meta.page; 175 | let totalPages = this.state.totalPages; 176 | let numEndEle = this.state.stockRecord.meta.pagerEndSize; 177 | let iterArray = Array.apply(null, { length: numEndEle }); // create array as basis for map in frag to iterate 178 | return ( 179 | 180 | {iterArray.map((o, c) => { 181 | let p = iterArray.length - (c + 1); // flip count order 182 | let page = totalPages - p || 0; 183 | return ( 184 |
  • pagerMainSize 188 | ? linkedPage === page 189 | ? "active page-item" 190 | : "page-item" 191 | : "d-none" 192 | } 193 | > 194 | 203 |
  • 204 | ); 205 | })} 206 |
    207 | ); 208 | } 209 | 210 | render() { 211 | return ( 212 |
    213 | {this.currentPage()} 214 | 223 |
    224 | ); 225 | } 226 | } 227 | 228 | export default Paginate; 229 | -------------------------------------------------------------------------------- /src/loginform.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./css/loginform.css"; 3 | import "bootstrap/dist/css/bootstrap.css"; 4 | import "bootstrap/dist/js/bootstrap.js"; 5 | import processRequest from "./api"; 6 | 7 | class LoginForm extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | // Remember! This binding is necessary to make `this` work in the callback 11 | this.handleLogin = this.handleLogin.bind(this); 12 | this.initialState = { 13 | formToDisplay: "", 14 | authenticated: this.props.authMeta.authenticated, 15 | password: "", 16 | username: "", 17 | oldPassword: "", 18 | newPassword: "", 19 | messages: { 20 | success: { 21 | authenticated: "Authentication succeeded!", 22 | changedPW: `Password successfully changed. Please log back in with your new credentials!`, 23 | loggedOut: "Successfully logged out!" 24 | }, 25 | failure: { 26 | authenticated: "Authentication failed!", 27 | changedPW: "Password change failed!", 28 | loggedOut: "Full server logout failed! Please contact an admin!" 29 | } 30 | } 31 | }; 32 | this.state = JSON.parse(JSON.stringify(this.initialState)); 33 | } 34 | 35 | resetState() { 36 | this.setState(this.initialState); 37 | } 38 | 39 | componentDidMount() { 40 | this.setFormToDisplay(); 41 | } 42 | 43 | componentWillUnmount() { 44 | this.resetState(); 45 | } 46 | 47 | componentDidUpdate(prevProps, prevState) { 48 | if (prevState.authenticated !== this.state.authenticated) { 49 | this.setFormToDisplay(); 50 | } 51 | } 52 | 53 | static getDerivedStateFromProps(nextProps) { 54 | return { 55 | authenticated: nextProps.authMeta.authenticated, 56 | csrfToken: nextProps.csrfToken 57 | }; 58 | } 59 | 60 | setFormToDisplay({ form = null } = {}) { 61 | if (form) { 62 | this.setState({ formToDisplay: form }); 63 | } else { 64 | this.setState({ 65 | formToDisplay: this.state.authenticated ? "logout" : "login" 66 | }); 67 | } 68 | } 69 | 70 | handleLogin() { 71 | let username = this.state.username; 72 | let password = this.state.password; 73 | this.setState({ username: "", password: "" }); 74 | this.authenticate({ 75 | apiMode: this.props.apiOptions.POST_AUTH, 76 | requestData: { 77 | data: { 78 | username: username, 79 | password: password 80 | } 81 | } 82 | }); 83 | this.resetState(); 84 | } 85 | 86 | handleLogout() { 87 | // logout of server 88 | this.authenticate({ 89 | apiMode: this.props.apiOptions.POST_LOGOUT 90 | }); 91 | // delete local session data 92 | this.props.deleteSessionStorage(["token", "username"]); 93 | this.resetState(); 94 | this.props.setAuthentication(); 95 | } 96 | 97 | handleChangePassword() { 98 | let oldPassword = this.state.oldPassword; 99 | let newPassword = this.state.newPassword; 100 | this.setState({ oldPassword: "", newPassword: "" }); 101 | this.authenticate({ 102 | apiMode: this.props.apiOptions.PATCH_CHANGE_PW, 103 | requestData: { 104 | data: { 105 | old_password: oldPassword, 106 | new_password: newPassword, 107 | username: this.props.getSessionStorage("username") 108 | } 109 | } 110 | }); 111 | this.resetState(); 112 | } 113 | 114 | authenticate({ apiMode = null, requestData = {} } = {}) { 115 | // triggers API to get auth token 116 | const apiRequest = processRequest({ 117 | apiMode: apiMode, 118 | csrfToken: this.state.csrfToken, 119 | requestData: requestData.data 120 | }); 121 | if (apiRequest) { 122 | apiRequest 123 | .then(response => { 124 | if (response && Object.prototype.hasOwnProperty.call(response.data, "token")) { 125 | // logged in 126 | this.props.deleteSessionStorage(["token", "username"]); // ensure any existing token deleted 1st 127 | this.props.setSessionStorage({ 128 | key: "token", 129 | value: response.data.token 130 | }); 131 | this.props.setSessionStorage({ 132 | key: "username", 133 | value: requestData.data.username 134 | }); 135 | this.props.setMessage({ 136 | // display message 137 | message: this.state.messages.success.authenticated, 138 | messageClass: "alert alert-success" 139 | }); 140 | this.props.setAuthentication(); 141 | } 142 | if (Object.prototype.hasOwnProperty.call(response.data, "password")) { 143 | // changed password 144 | if (response.data.password === "CHANGED") { 145 | this.props.deleteSessionStorage(["token", "username"]); // delete existing token to enforce re-login 146 | this.props.setMessage({ 147 | message: this.state.messages.success.changedPW, 148 | messageClass: "alert alert-success" 149 | }); 150 | this.props.setAuthentication(); 151 | } else { 152 | this.props.setMessage({ 153 | message: this.state.messages.failure.changedPW, 154 | messageClass: "alert alert-success" 155 | }); 156 | } 157 | } 158 | if (Object.prototype.hasOwnProperty.call(response.data, "logged_in")) { 159 | // logout 160 | if (!response.data.logged_in) { 161 | this.props.setMessage({ 162 | message: this.state.messages.success.loggedOut, 163 | messageClass: "alert alert-success" 164 | }); 165 | } else { 166 | this.props.setMessage({ 167 | message: this.state.messages.failure.loggedOut, 168 | messageClass: "alert alert-danger" 169 | }); 170 | } 171 | } 172 | }) 173 | .catch(error => { 174 | let displayError = null; 175 | if (error.response && error.response.data) { 176 | let firstError = Object.keys(error.response.data)[0]; 177 | displayError = error.response.data[firstError][0]; 178 | } 179 | this.resetState(); 180 | this.props.setMessage({ 181 | message: `${ 182 | displayError 183 | ? displayError 184 | : this.state.messages.failure.authenticated 185 | }`, 186 | messageClass: "alert alert-danger" 187 | }); 188 | }); 189 | } 190 | } 191 | 192 | receivePassword(e) { 193 | this.setState({ password: e.target.value }); 194 | } 195 | 196 | receiveUsername(e) { 197 | this.setState({ username: e.target.value }); 198 | } 199 | 200 | receiveOldPassword(e) { 201 | this.setState({ oldPassword: e.target.value }); 202 | } 203 | 204 | receiveNewPassword(e) { 205 | this.setState({ newPassword: e.target.value }); 206 | } 207 | 208 | render() { 209 | let displayForm; 210 | switch (this.state.formToDisplay) { 211 | case "login": 212 | default: 213 | displayForm = ( 214 |
    215 |
    216 |
    217 | this.receiveUsername(e)} 222 | /> 223 |
    224 |
    225 | this.receivePassword(e)} 231 | /> 232 |
    233 |
    234 | 243 |
    244 |
    245 |
    246 | ); 247 | break; 248 | case "logout": 249 | displayForm = ( 250 |
    251 | 252 | Welcome {this.props.getSessionStorage("username")}! 253 | 254 | 260 | 266 |
    267 | ); 268 | break; 269 | case "changePassword": 270 | displayForm = ( 271 |
    272 |
    273 |
    274 | this.receiveOldPassword(e)} 281 | /> 282 |
    283 |
    284 | this.receiveNewPassword(e)} 291 | /> 292 |
    293 |
    294 | 303 |
    304 |
    305 |
    306 | ); 307 | break; 308 | } 309 | return ( 310 | 311 |
    {displayForm}
    312 |
    313 | ); 314 | } 315 | } 316 | 317 | export default LoginForm; 318 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | import axios from "axios/index"; 2 | 3 | const processRequest = ({ 4 | stockRecord = null, 5 | requestData = null, 6 | apiMode = null, 7 | csrfToken = null, 8 | url = null 9 | } = {}) => { 10 | const { requestType, method } = apiMode; 11 | if (apiMode && requestType) { 12 | if (requestType === "get_stock") { 13 | return _getStock({ stockRecord, csrfToken, requestMethod: method, url }); // returns a promise 14 | } else if (requestType === "get_account_stock") { 15 | return _getAccountStock({ 16 | stockRecord, 17 | csrfToken, 18 | requestMethod: method, 19 | url 20 | }); // returns a promise 21 | } else if (requestType === "get_take_stock") { 22 | return _getTakeStock({ 23 | stockRecord, 24 | csrfToken, 25 | requestMethod: method, 26 | url 27 | }); // returns a promise 28 | } else if (requestType === "patch_stock") { 29 | return _updateStock({ 30 | stockRecord, 31 | csrfToken, 32 | requestMethod: method, 33 | url 34 | }); // returns a promise 35 | } else if (requestType === "patch_account_stock") { 36 | return _updateAccountStock({ 37 | stockRecord, 38 | csrfToken, 39 | requestMethod: method, 40 | url 41 | }); // returns a promise 42 | } else if (requestType === "add_stock") { 43 | return _addStock({ stockRecord, csrfToken, requestMethod: method, url }); // returns a promise 44 | } else if (requestType === "add_account_stock") { 45 | return _addAccountStock({ 46 | stockRecord, 47 | csrfToken, 48 | requestMethod: method, 49 | url 50 | }); // returns a promise 51 | } else if (requestType === "delete_stock_line") { 52 | return _deleteStock({ stockRecord, csrfToken, requestMethod: method }); 53 | } else if (requestType === "delete_account_stock_line") { 54 | return _deleteAccountStock({ 55 | stockRecord, 56 | csrfToken, 57 | requestMethod: method 58 | }); 59 | } else if ( 60 | requestType === "post_auth" || 61 | requestType === "patch_change_pw" || 62 | requestType === "post_logout" 63 | ) { 64 | return _auth({ csrfToken, requestData, apiMode, requestMethod: method }); 65 | } 66 | } 67 | console.log(` 68 | No API mode, or stock record configuration set. API requested failed. 69 | Pertinent variable values: requestType=${requestType}; method=${method}`); 70 | return false; 71 | }; 72 | 73 | const _getSessionStorage = key => { 74 | //return JSON.parse(localStorage.getItem(key)); 75 | return sessionStorage.getItem(key); 76 | }; 77 | 78 | const _makeRequest = ({ 79 | stockRecord = null, 80 | requestMethod = null, 81 | csrfToken = null, 82 | requestData = null, 83 | url = null, 84 | } = {}) => { 85 | const CancelToken = axios.CancelToken; 86 | let cancel; 87 | // if no requestData passed, see if update data in stock record data. 88 | if (!requestData && stockRecord !== null) { 89 | requestData = stockRecord.data.updateData; 90 | } 91 | if (url && requestMethod) { 92 | // make request 93 | if (cancel !== undefined) { 94 | cancel(); 95 | console.log( 96 | "API request cancelled because an existing request was already underway!" 97 | ); 98 | } 99 | return axios({ 100 | cancelToken: new CancelToken(c => (cancel = c)), 101 | method: requestMethod, 102 | url: url, 103 | responseType: "json", 104 | data: requestData, 105 | //auth: {}, 106 | headers: { 107 | Authorization: _getSessionStorage("token") 108 | ? `Token ${_getSessionStorage("token")}` 109 | : null, 110 | //'cache-control': 'no-cache', 111 | "Content-Type": "application/json", 112 | "X-CSRFToken": csrfToken 113 | } // additional headers here 114 | }); 115 | } 116 | console.log("API did not send a request"); 117 | }; 118 | 119 | const _getStock = ({ 120 | stockRecord = null, 121 | csrfToken = null, 122 | requestMethod = null, 123 | url = null 124 | } = {}) => { 125 | // get stock for warehouse 126 | if (stockRecord) { 127 | if (!url) { 128 | let { pageOrderBy, pageOrderDir, search, limit, page } = stockRecord.meta; 129 | if (!url) { 130 | // constructs request URL, unless pre-defined in paginate.js through api 'next' or 'previous'. 131 | // build url 132 | url = 133 | `${process.env.REACT_APP_API_DATA_ROUTE}/stock/?limit=${limit}` + 134 | `&offset=${page * limit - limit}` + 135 | `&order_by=${pageOrderDir}${pageOrderBy}&desc=${search}`; // update URL 136 | } 137 | } 138 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); // returns a promise 139 | } 140 | return false; 141 | }; 142 | 143 | const _getAccountStock = ({ 144 | stockRecord = null, 145 | csrfToken = null, 146 | requestMethod = null, 147 | url = null 148 | } = {}) => { 149 | // get stock for store account 150 | if (stockRecord) { 151 | if (!url) { 152 | let { pageOrderBy, pageOrderDir, search, limit, page } = stockRecord.meta; 153 | if (!url) { 154 | // constructs request URL, unless pre-defined in paginate.js through api 'next' or 'previous'. 155 | // build url 156 | url = 157 | `${ 158 | process.env.REACT_APP_API_DATA_ROUTE 159 | }/accounts/stock/?limit=${limit}` + 160 | `&offset=${page * limit - limit}` + 161 | `&order_by=${pageOrderDir}${pageOrderBy}&desc=${search}`; // update URL 162 | } 163 | } 164 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); // returns a promise 165 | } 166 | return false; 167 | }; 168 | 169 | const _getTakeStock = ({ 170 | stockRecord = null, 171 | csrfToken = null, 172 | requestMethod = null, 173 | url = null 174 | } = {}) => { 175 | // initiate stock take 176 | url = url 177 | ? url 178 | : `${process.env.REACT_APP_API_DATA_ROUTE}/accounts/take-stock/`; 179 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 180 | }; 181 | 182 | const _updateStock = ({ 183 | stockRecord = null, 184 | csrfToken = null, 185 | requestMethod = null, 186 | url = null 187 | } = {}) => { 188 | // manager's transfer updates - updates a truck load! 189 | if ("records" in stockRecord) { 190 | url = `${process.env.REACT_APP_API_DATA_ROUTE}/stock/`; 191 | return _makeRequest({ 192 | requestData: { ...stockRecord }, 193 | csrfToken, 194 | requestMethod, 195 | url 196 | }); 197 | } else if (stockRecord) { 198 | // admin's stock record updates - updates single line (not truck) 199 | // build url 200 | url = url 201 | ? url 202 | : `${process.env.REACT_APP_API_DATA_ROUTE}/stock/${ 203 | stockRecord.data.updateData.id 204 | }/`; 205 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 206 | } 207 | return false; 208 | }; 209 | 210 | const _updateAccountStock = ({ 211 | stockRecord = null, 212 | csrfToken = null, 213 | requestMethod = null, 214 | url = null 215 | } = {}) => { 216 | // store account updates 217 | if (stockRecord) { 218 | // admin's stock record updates 219 | // build url 220 | url = url 221 | ? url 222 | : `${process.env.REACT_APP_API_DATA_ROUTE}/accounts/stock/${ 223 | stockRecord.data.updateData.id 224 | }/`; 225 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 226 | } 227 | return false; 228 | }; 229 | 230 | const _addStock = ({ 231 | stockRecord = null, 232 | csrfToken = null, 233 | requestMethod = null, 234 | url = null 235 | } = {}) => { 236 | if (stockRecord) { 237 | // build url 238 | url = url ? url : `${process.env.REACT_APP_API_DATA_ROUTE}/stock/`; 239 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 240 | } 241 | return false; 242 | }; 243 | 244 | const _addAccountStock = ({ 245 | stockRecord = null, 246 | csrfToken = null, 247 | requestMethod = null, 248 | url = null 249 | } = {}) => { 250 | if (stockRecord) { 251 | // build url 252 | url = url ? url : `${process.env.REACT_APP_API_DATA_ROUTE}/accounts/stock/`; 253 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 254 | } 255 | return false; 256 | }; 257 | 258 | const _deleteStock = ({ 259 | stockRecord = null, 260 | csrfToken = null, 261 | requestMethod = null, 262 | url = null 263 | } = {}) => { 264 | if (stockRecord) { 265 | // build url 266 | url = url 267 | ? url 268 | : `${process.env.REACT_APP_API_DATA_ROUTE}/stock/${ 269 | stockRecord.data.updateData.id 270 | }/`; 271 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 272 | } 273 | return false; 274 | }; 275 | 276 | const _deleteAccountStock = ({ 277 | stockRecord = null, 278 | csrfToken = null, 279 | requestMethod = null, 280 | url = null 281 | } = {}) => { 282 | if (stockRecord) { 283 | // build url 284 | url = url 285 | ? url 286 | : `${process.env.REACT_APP_API_DATA_ROUTE}/accounts/stock/${ 287 | stockRecord.data.updateData.id 288 | }/`; 289 | return _makeRequest({ stockRecord, csrfToken, requestMethod, url }); 290 | } 291 | return false; 292 | }; 293 | 294 | const _auth = ({ 295 | requestMethod = null, 296 | csrfToken = null, 297 | requestData = null, 298 | apiMode = null 299 | } = {}) => { 300 | const apiRoute = process.env.REACT_APP_API_ROUTE; 301 | const loginURL = `${apiRoute}/api-token-auth/`; 302 | const logoutURL = `${apiRoute}/v2/logout/`; 303 | const changePWURL = `${apiRoute}/v2/change-password/${_getSessionStorage( 304 | "username" 305 | )}/`; 306 | let url = null; 307 | switch (apiMode.requestType) { 308 | case "patch_change_pw": 309 | url = changePWURL; 310 | break; 311 | case "post_logout": 312 | url = logoutURL; 313 | break; 314 | default: 315 | url = loginURL; 316 | } 317 | const cacheControl = "no-cache"; 318 | return _makeRequest({ 319 | requestMethod, 320 | requestData, 321 | csrfToken, 322 | cacheControl, 323 | url: url 324 | }); 325 | }; 326 | 327 | export default processRequest; 328 | 329 | /* 330 | Note 1: 331 | Method to generate the request ordering. Returns '' or '-'. 332 | Reverses ordering if column clicked when already ordered on the same column. Otherwise, defaults to ascending order. 333 | Note: page always reverts to page 1 IF column clicked to change order on any page other than 1, as new ordering 334 | is requested from API. Needs to return to page 1 to display the newly requested (differently sorted) pages - 335 | otherwise the current page would display any data that happened to correspond to that page of newly received data, 336 | and that wouldn't be expected behaviour. 337 | Could also add method (& UI link/button/widget) for columns to LOCALLY order/sort presently displayed page data 338 | (without an API request) if required. 339 | */ 340 | -------------------------------------------------------------------------------- /assets/js/data-table.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Paginate from './paginate.jsx'; 3 | import '../css/data-table.css'; 4 | import '../css/vendor/bootstrap-4.0.0/bootstrap.min.css'; 5 | import './vendor/bootstrap-4.0.0/bootstrap.min.js' 6 | import {FontAwesomeIcon} from '@fortawesome/react-fontawesome' 7 | import moment from 'moment' 8 | import 'moment/locale/en-gb.js' 9 | import 'moment-timezone' 10 | 11 | class DataTable extends React.Component { 12 | 13 | constructor(props) { 14 | super(props); 15 | // keep real-time props changes in component state 16 | this.state = { 17 | modalIsOpen: false, 18 | dataTable: null, 19 | message: null, 20 | messageClass: '', 21 | datetimeOfRequest: null, 22 | search: '' 23 | }; 24 | } 25 | 26 | static formatUTCDateTime({dateTime = null} = {}) { 27 | // takes datetime in UTC, formats and returns datetime in user's browser reported timezone 28 | return dateTime ? `${moment.utc(dateTime).local() 29 | .format('DD/MM/YYYY HH:mm:ss')} ${moment.tz(moment.tz.guess()).zoneAbbr()}` 30 | : null; 31 | } 32 | 33 | componentWillMount() { 34 | // kick it off, make a request for stock data ... 35 | this.handleGetRecords({stockRecord: {meta: this.props.stockRecordMeta}}); 36 | } 37 | 38 | componentWillUnmount() { 39 | } 40 | 41 | componentWillReceiveProps(nextProps) { 42 | // generate new table 43 | this.setState({ 44 | message: nextProps.message, 45 | messageClass: nextProps.messageClass, 46 | datetimeOfRequest: DataTable.formatUTCDateTime( 47 | {dateTime: nextProps.stockRecordMeta.datetime_of_request}), 48 | search: nextProps.stockRecordMeta.search, 49 | dataTable: 50 | this.generateTable({ 51 | stockRecordData: nextProps.stockRecordData, 52 | stockRecordMeta: nextProps.stockRecordMeta 53 | }), 54 | }); 55 | } 56 | 57 | handleGetRecords({stockRecord = {}} = {}) { 58 | this.props.setMessage({message: null}); // clear old messages 59 | this.props.setStockRecordState({ 60 | stockRecord: stockRecord, 61 | apiTrigger: this.props.API_OPTIONS.GET_STOCK 62 | }); // set record request state 63 | } 64 | 65 | handleAddRecord() { 66 | this.props.setMessage({message: null}); // clear old messages 67 | this.props.openStockUpdateModalHandler({record: {meta: {newRecord: true}}}); // open modal 68 | } 69 | 70 | handleEditRecord({record = null} = {}) { 71 | this.props.setMessage({message: null}); // clear old messages 72 | this.props.openStockUpdateModalHandler({record: record}); // open update modal 73 | } 74 | 75 | handleDeleteLine({record = null} = {}) { 76 | this.props.setMessage({message: null}); // clear old messages 77 | this.props.openStockUpdateModalHandler({record: {data: record, meta: {deleteRecord: true}}}) 78 | } 79 | 80 | handleSearch(e) { 81 | this.handleGetRecords({ 82 | stockRecord: { 83 | meta: { 84 | orderBy: 'desc', page: 1, 85 | search: this.validateDesc(e.target.value) 86 | } 87 | } 88 | }); 89 | } 90 | 91 | validateDesc(value) { 92 | return (/^[a-zA-Z\d.\- ]*$/.test(value)) ? value : this.state.search 93 | } 94 | 95 | generateTable({stockRecordData = {}, stockRecordMeta = {}} = {}) { 96 | let navBar = () => { 97 | return (
    98 |
    99 |
    100 |
    101 | 106 |
    107 |
    108 | 111 |
    112 |
    113 |
    114 | 119 |
    120 |
    121 | 126 |
    127 |
    128 |
    129 | ) 130 | }; 131 | let noData = () => { 132 | return ( 133 |
    134 | {navBar()} 135 |
    Loading data ...
    136 |
    137 | ); 138 | }; 139 | let resultData = ({stockData = null} = {}) => { 140 | return stockData.data.results.length ? 141 | stockData.data.results.map((item) => { 142 | return ( 143 | {/*{item.id}*/} 144 | {item.sku} 145 | {item.desc} 146 | {item.units_total} 147 | {item.unit_price} 148 | {DataTable.formatUTCDateTime({dateTime: item.record_updated})} 150 | 151 | 154 | {stockRecordMeta.userIsAdmin ? 155 | : ''} 158 | 159 | ) 160 | }) : null; 161 | }; 162 | let table = () => { 163 | let data = resultData({stockData: stockRecordData}); 164 | return data ? ( 165 |
    166 | {navBar()} 167 |
    168 |
    169 |
    170 | 171 | 175 | 176 | 177 | 182 | 187 | 192 | 197 | 202 | 205 | 206 | 207 | 208 | {data} 209 | 210 |
    {this.props.APP_DETAILS.shortOrgName} Stock 172 | Data {stockRecordMeta.datetime_of_request ? 173 | `[Request returned: ${this.state.datetimeOfRequest}]` : ''} 174 |
    this.handleGetRecords({ 178 | stockRecord: {meta: {orderBy: 'sku', page: 1}} 179 | })}> 180 | SKU 181 | this.handleGetRecords({ 183 | stockRecord: {meta: {orderBy: 'desc', page: 1}} 184 | })}> 185 | Description 186 | this.handleGetRecords({ 188 | stockRecord: {meta: {orderBy: 'units_total', page: 1}} 189 | })}> 190 | Units 191 | this.handleGetRecords({ 193 | stockRecord: {meta: {orderBy: 'unit_price', page: 1}} 194 | })}> 195 | Unit Price 196 | this.handleGetRecords({ 198 | stockRecord: {meta: {orderBy: 'record_updated', page: 1}} 199 | })}> 200 | Record Updated 201 | 203 | Action 204 |
    211 |
    212 |
    213 |
    214 |
    215 | ) : null; 216 | }; 217 | return table() ? table() : noData(); 218 | } 219 | 220 | render() { 221 | return ( 222 |
    223 | {this.state.dataTable} 224 |
    225 | ) 226 | } 227 | } 228 | 229 | export default DataTable; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Stock Management - Frontend Client Component 2 | 3 | ## Security Advisory 4 | 5 | Please note, this application has not been audited for security and *probably does* contain vulnerabilities that could expose data contained on the host system to unauthorized manipulation or disclosure, both during the building process and deployment. Use at your own risk. 6 | 7 | ## About 8 | 9 | This a demo/prototype repository for the frontend web client for the Simple Stock Management stock and inventory system. It is built using web technologies, with a client/server architecture. The repository for the server component is at: https://github.com/Aninstance/simple-stock-management 10 | 11 | The system allows "stores" to request transfers of stock ("order") from a central stock repository ("warehouse"). Stock is adjusted for the "Warehouse Account" and the "Store Account" as stock transfers are "ordered". Email notifications are sent to the "warehouse" administrator(s) and the ordering "store manager". 12 | 13 | This project - available to subscribers and clients as a regularly maintained application-as-a-service - offers a web frontend that connects to a RESTful API backend. Data is stored in either a SQLite, mySQL or PostgreSQL (recommended) database. 14 | 15 | ## Support & Project Status 16 | 17 | A regularly patched, proprietary licensed application-as-a-service version, fully maintained for subscribers and clients, is available upon request (limited availability) and is currently priced at £10.00/month. 18 | 19 | A one-off installation service for this GPL open source version is also available. 20 | 21 | The GPL licensed version of this project offered here is *not guaranteed* to be regularly maintained. It is made available here for demo/prototype purposes only, and should not be used in production (i.e. a "live" working environment) unless the administrator regularly patches project dependencies (i.e. PYPI & npm packages) with upstream security updates as and when released by vendors. 22 | 23 | If you would like to avail of the proprietary subscription to the application-as-a-service, or request other bespoke work on this project, please email to discuss: ssm@uplandsdynamic.com. 24 | 25 | 26 | ## Key Technologies for Client Component 27 | 28 | Key technologies include: Javascript (ReactJS); HTML5; CSS3; BootStrap 4. 29 | 30 | ## Live Demo 31 | 32 | There is a live demo, available here: 33 | 34 | https://frontend.ssm.webapps.uplandsdynamic.com 35 | 36 | There are two test users - one for the warehouse administrator, the other for a 'store manager'. Credentials are: 37 | 38 | Adminstrator: 39 | Username: test_admin 40 | Password: jduejHje(89K 41 | 42 | Manager: 43 | Username: test_manager 44 | Password: jduejHje(89K 45 | 46 | ## Screenshots 47 | 48 | ![Screenshot 1](./meta/img/screenshot_1.png?raw=true) 49 | ![Screenshot 2](./meta/img/screenshot_2.png?raw=true) 50 | ![Screenshot 4](./meta/img/screenshot_4.png?raw=true) 51 | 52 | ![Screenshot 6](./meta/img/screenshot_6.png?raw=true) 53 | ![Screenshot 7](./meta/img/screenshot_7.png?raw=true) 54 | ![Screenshot 8](./meta/img/screenshot_8.png?raw=true) 55 | ![Screenshot 5](./meta/img/screenshot_5.png?raw=true) 56 | ![Screenshot 9](./meta/img/screenshot_9.png?raw=true) 57 | ![Screenshot 10](./meta/img/screenshot_10.png?raw=true) 58 | 59 | ## Key features 60 | 61 | - Administrator may add, edit and delete stock from database. 62 | - Store managers may request transfers ("order") stock from the "warehouse". 63 | - Dynamic search of stock lines (SKU and description). 64 | - Configurable pagination of results table. 65 | - Transfer requests of stock lines are loaded to a "truck" (i.e. like "adding to a basket/cart" in an e-commerce system), before the request is submitted. 66 | - The "truck" retains the transfer data until the "Request truck dispatch" button is clicked. The truck data is retained across sessions (meaning the data remains in the truck even if the user logs out, then resumes their transfer at a later time). 67 | - Once the "Request truck dispatch" button is clicked, the transfer request process will complete. The truck empties and a single email containing a summary of the successful transfers - and any failures - is dispatched to both the requesting user and the warehouse administrator. Warehouse quantities are immediately adjusted accordingly, both in the "Warehouse" and "Store" accounts. 68 | - A "Stock take" feature compiles and emails detailed reports, consisting of: 69 | 70 | - For every unique stock line in a "Store Account" (see screenshot #10, below, for an example report): 71 | 72 | - SKU 73 | - Stock description 74 | - Units of opening stock 75 | - Units of closing stock 76 | - Change in stock units since last stock take 77 | - Number of units transferred since last stock take 78 | - Number of units recorded sold since last stock take 79 | - Number of units recorded as shrinkage since last stock take 80 | - Differential for units of unrecorded history since last stock take (i.e. unrecorded sales, unrecorded transfers, unrecorded loss) 81 | - Current transfer value of a unit 82 | - Current retail price of a unit 83 | - Total value of units recorded sold since last stock take 84 | - Total value of units recorded as shrinkage since last stock take 85 | - Total value of units transferred since last stock take 86 | - Total value differential of units with unrecorded history since last stock take, at present xfer price 87 | - Total value differential of units with unrecorded history since last stock take, at present retail price (i.e. unrecorded sales, unrecorded transfers, shrinkage) 88 | - Current total held stock transfer value at present xfer price 89 | - Current total held stock retail value at present retail price 90 | 91 | - Overview of the entire "Store Account" (see screenshot #10, below, for an example report): 92 | 93 | - Units of opening stock 94 | - Units of closing stock 95 | - Units of stock transferred since last stock take 96 | - Units of stock recorded sold since last stock take 97 | - Units of stock recorded as shrinkage since last stock take 98 | - Change in stock holding owing to unrecorded unit history since last stock take (i.e. unrecorded sales, unrecorded transfers, unrecorded loss) 99 | - Value of stock recorded sold since last stock take 100 | - Value of stock recorded as shrinkage since last stock take 101 | - Total value differential of units with unrecorded history since last stock take at current transfer value 102 | - Total value differential of units with unrecorded history since last stock take at current retail value (i.e. unrecorded sales, unrecorded transfers, unrecorded loss) 103 | - Total value of transfers since last stock take (at actual xfer prices) 104 | - All time total value of transfers (at actual xfer prices) 105 | - Value of held stock at current transfer price 106 | - Value of held stock at current retail price 107 | 108 | - Automated removal of obsolete stock line records (lines with zero units of held stock) from the Store accounts following a successful stock take process 109 | - Historical retention of previous stock take data (not currently exposed on the UI) 110 | 111 | ## Brief UI instructions 112 | 113 | Warehouse administrators: 114 | 115 | - Plus sign button allows adding new stock lines 116 | - Circular arrows button refreshes records from the database 117 | - Pencil icon button in `Action` column allows editing of stock line 118 | - Dustbin icon button in `Action` column allows deletion of a stock line 119 | 120 | Store account managers: 121 | 122 | - Head-&-shoulders icon (right of top header bar) switches between `Warehouse` account (from where transfers are requested) and the user's `Store` account 123 | - Truck icon (right of top header bar) opens the user's "transfer truck" 124 | - Circular arrows button refreshes records from the database 125 | - Plus sign button allows manual addition of new lines to the `Store` account 126 | - Pencil icon button in `Action` column allows editing of stock line data (e.g. change stock level, record a sale or shrinkage, etc) 127 | - `New shrinkage` & `New recorded sold` update fields are ***disabled** during a stock line edit if the `stock quantity` field is changed. This is to prevent user error by inadvertent duplication of submitted data (i.e. user manually decrementing the `stock quantity` field whilst also recording the same data as `New recorded sold`). Likewise, the `stock quantity` field is disabled if the `New shrinkage` and/or `New recorded sold` fields are edited, for the same reason 128 | - Eye icon button initiates a stock take 129 | 130 | ## Installation & Usage (on Linux systems) 131 | 132 | __Below are basic steps to install and run a demonstration of the app on an Linux Ubuntu 20.04 server. They do not provide for a secure installation, such as would be required if the app was publicly available. Steps should be taken to security harden the environment if using in production.__ 133 | 134 | ### Brief Installation Instructions 135 | 136 | - Clone repository to an app source directory of your choice, e.g. `git clone https://github.com/Aninstance/simple-stock-management-frontend.git`. 137 | - Make web root directory, e.g. `mkdir -p /var/www/html/ssm-frontend`. 138 | - Move into the cloned directory and install the required node packages. Ensure a current version of NPM is installed and active on your system - it's recommended to install NVM (Node Version Manager) - which allows multiple node versions to be installed - to avoid changing a pre-installed version that may be required by other software or packages on your system. 139 | - Install npx on your system if not already installed, e.g.: `npm install -g npx`. 140 | - Install the packages by running `npm install`. 141 | - In the event of any vulnerabilities being flagged up, run `npm audit fix`. 142 | - Configure the app environment by coping the `.env.production_DEFAULT` file to `.env.production` and editing according to your requirements. 143 | - Build the app, e.g.: `npm run build:production`. 144 | - Copy the built app into its web directory, e.g. `cp -a build/. /var/www/html/ssm-frontend/;` `cp -a /var/www/html/ssm-frontend/static. /var/www/html/ssm-frontend/;`. 145 | - Recursively change ownership of the `ssm-frontend` directory to your web server user. 146 | - Configure your web server to serve the app from your web directory (this is straight forward, but outwith the scope of this document. If you need further help, please check your web server's documentation). 147 | 148 | ### Update Instructions 149 | 150 | - Ensure you backup a copy of changes to your environment configuration - make a copy of your `.env.production` file *outside* of your cloned directory. 151 | - From the build directory, run `git pull`. 152 | - Remove existing node modules, e.g.: `rm -rf node_modules`. 153 | - Remove the package lock file, e.g.: `rm -rf package-lock.json`. 154 | - Install updated modules, e.g.: `npm install`. 155 | - In the event of any vulnerabilities being flagged up, run `npm audit fix`. 156 | - Rebuild the project, e.g.: `npm run build:production`. 157 | - Once the app has been built, copy to the web directory: `cp -a build/. /var/www/html/ssm-frontend/;` `cp -a /var/www/html/ssm-frontend/static/. /var/www/html/ssm-frontend/;`. 158 | - Recursively change ownership of the `ssm-frontend` directory to your web server user. 159 | - Restart your web server. 160 | 161 | Note: The above guide is not definitive and is intended for users who know their way around Ubuntu server and Django. 162 | 163 | *Users would need to arrange database backups and to secure the application appropriately when used in a production environment.* 164 | 165 | ## Development Roadmap 166 | 167 | - No new features planned at present. To request a change or additional functionality, or to file a bug, please open a github issue and/or contact dan@uplandsdynamic.com. 168 | 169 | ## Authors 170 | 171 | - Dan Bright (Uplands Dynamic), dan@uplandsdynamic.com 172 | -------------------------------------------------------------------------------- /src/stock-update-modal.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./css/stock-update-modal.css"; 3 | import Modal from "react-modal"; 4 | import StockUpdateTable from "./stock-update-table.js"; 5 | import StockUpdateDelete from "./stock-update-delete.js"; 6 | import processRequest from "./api.js"; 7 | 8 | Modal.setAppElement(document.body); 9 | 10 | Modal.defaultStyles.overlay.backgroundColor = "cornflowerblue"; 11 | const REGULAR_STYLES = { 12 | content: { 13 | top: "50%", 14 | left: "50%", 15 | right: "auto", 16 | bottom: "auto", 17 | marginRight: "-50%", 18 | transform: "translate(-50%, -50%)", 19 | backgroundColor: "#001e00", 20 | color: "yellow", 21 | border: "1px solid yellow", 22 | borderRadius: "7px 7px 7px 7px", 23 | boxShadow: "-7px -7px 17px 7px #001e00", 24 | maxWidth: "800px", 25 | maxHeight: "95%" 26 | }, 27 | overlay: { 28 | backgroundColor: "#2a3517" 29 | } 30 | }; 31 | 32 | const REGULAR_STYLES_ACCOUNT = { 33 | content: { 34 | top: "50%", 35 | left: "50%", 36 | right: "auto", 37 | bottom: "auto", 38 | marginRight: "-50%", 39 | transform: "translate(-50%, -50%)", 40 | backgroundColor: "#000033", 41 | color: "yellow", 42 | border: "1px solid yellow", 43 | borderRadius: "7px 7px 7px 7px", 44 | boxShadow: "-7px -7px 17px 7px 000033", 45 | maxWidth: "800px", 46 | maxHeight: "95%" 47 | }, 48 | overlay: { 49 | backgroundColor: "#000033" 50 | } 51 | }; 52 | 53 | const DANGER_STYLES = { 54 | content: { 55 | top: "50%", 56 | left: "50%", 57 | right: "auto", 58 | bottom: "auto", 59 | marginRight: "-50%", 60 | transform: "translate(-50%, -50%)", 61 | backgroundColor: "darkred", 62 | color: "white", 63 | border: "1px solid yellow", 64 | borderRadius: "7px 7px 7px 7px", 65 | boxShadow: "-7px -7px 17px 7px black", 66 | maxWidth: "800px" 67 | }, 68 | overlay: { 69 | backgroundColor: "gold" 70 | } 71 | }; 72 | 73 | /* 74 | Note: Various ways to style the modal: 75 | - css stylesheets 76 | - inline (as above, set in state (modalStyles)) 77 | - default styles (also as above, as used for overlay background color). 78 | For more info. see: https://reactcommunity.org/react-modal/examples/css_classes.html 79 | */ 80 | 81 | class StockUpdateModal extends React.Component { 82 | constructor(props) { 83 | super(props); 84 | this.state = { 85 | modalStyles: REGULAR_STYLES, 86 | modalIsOpen: this.props.openStockUpdateModal.state, 87 | deleteRecord: this.props.openStockUpdateModal.deleteRecord, 88 | newRecord: this.props.openStockUpdateModal.newRecord 89 | }; 90 | // Remember! This binding is necessary to make `this` work in the callback 91 | this.handleAfterOpenModal = this.handleAfterOpenModal.bind(this); 92 | this.handleCloseModal = this.handleCloseModal.bind(this); 93 | } 94 | 95 | componentWillUnmount() { 96 | console.log("Unmounting stock update modal"); 97 | } 98 | 99 | static getDerivedStateFromProps(nextProps) { 100 | let newStyles = 101 | nextProps.accountMode === nextProps.accountModes.STORE 102 | ? REGULAR_STYLES_ACCOUNT 103 | : REGULAR_STYLES; 104 | if (nextProps.openStockUpdateModal.deleteRecord) { 105 | newStyles = DANGER_STYLES; 106 | } 107 | // set modal open/close state (source of truth in parent component - app.js) 108 | return { 109 | modalIsOpen: nextProps.openStockUpdateModal.state, 110 | deleteRecord: nextProps.openStockUpdateModal.deleteRecord, 111 | newRecord: nextProps.openStockUpdateModal.newRecord, 112 | modalStyles: newStyles 113 | }; 114 | } 115 | 116 | componentDidUpdate() {} 117 | 118 | handleAfterOpenModal() {} 119 | 120 | handleCloseModal({ 121 | stockRecord = this.props.stockRecord, 122 | actionCancelled = false 123 | } = {}) { 124 | // set modal state in parent component (app,js) back to closed 125 | this.props.setStockUpdateModalState({ 126 | stockRecord, 127 | state: false, 128 | actionCancelled: actionCancelled 129 | }); 130 | } 131 | 132 | dataUpdate({ 133 | stockRecord = this.props.stockRecord, 134 | updated = {}, 135 | commit = false 136 | } = {}) { 137 | if (commit) { 138 | // close modal (makes new api request for updated data following update) 139 | this.handleCloseModal({ stockRecord, actionCancelled: false }); 140 | } else { 141 | // called from update modal table, so update just the updateData values, pending eventual api call 142 | Object.assign(stockRecord.data.updateData, { ...updated }); 143 | this.props.setStockRecordState({ newStockRecord: stockRecord }); 144 | } 145 | } 146 | 147 | handleRecordUpdate({ 148 | adminUpdate = false, 149 | accountMode = this.props.accountModes.WAREHOUSE, 150 | accountModes = this.props.accountModes, 151 | newRecord = false, 152 | deleteRecord = false 153 | } = {}) { 154 | /* 155 | method to update or delete record data (cancel if desc or sku fields were empty) 156 | */ 157 | const { desc, sku } = this.props.stockRecord.data.updateData; 158 | const storeAccount = accountMode === accountModes.STORE; 159 | // if deleteRecord was passed in props (i.e. delete button on data table was clicked), prioritse that 160 | deleteRecord = this.state.deleteRecord 161 | ? this.state.deleteRecord 162 | : deleteRecord; 163 | /* if not admin, load the truck for transfer, rather than submit an API request */ 164 | if (!adminUpdate && !storeAccount) { 165 | this.props.loadTruck({ cargo: this.props.stockRecord.data.updateData }); 166 | this.handleCloseModal(); 167 | } else { 168 | /*Administrative update */ 169 | let stockRecord = JSON.parse(JSON.stringify(this.props.stockRecord)); // make a clone in case pre-request changes made 170 | /* these fields required to have a value unless deleting record (or just transferring) */ 171 | let reqFieldsComplete = !!( 172 | (desc && sku) || 173 | this.props.stockRecord.meta.deleteRecord 174 | ); 175 | // set API mode 176 | let apiMode = null; 177 | if (!storeAccount) { 178 | // set API mode for warehouse admin 179 | if (newRecord) { 180 | apiMode = this.props.apiOptions.ADD_STOCK; 181 | } else if (deleteRecord && adminUpdate) { 182 | apiMode = this.props.apiOptions.DELETE_STOCK_LINE; 183 | } else { 184 | apiMode = this.props.apiOptions.PATCH_STOCK; 185 | } 186 | } else { 187 | // st API mode for store accounts admin 188 | if (newRecord) { 189 | apiMode = this.props.apiOptions.ADD_ACCOUNT_STOCK; 190 | } else if (deleteRecord && adminUpdate) { 191 | apiMode = this.props.apiOptions.DELETE_ACCOUNT_STOCK_LINE; 192 | } else { 193 | apiMode = this.props.apiOptions.PATCH_ACCOUNT_STOCK; 194 | } 195 | } 196 | // Hit the api if required fields are complete 197 | if (reqFieldsComplete) { 198 | const apiRequest = processRequest({ 199 | stockRecord: stockRecord, 200 | apiMode: apiMode 201 | }); 202 | stockRecord = null; // delete the clone from memory 203 | if (apiRequest) { 204 | apiRequest 205 | .then(response => { 206 | if (response) { 207 | if (response.status === 200) { 208 | this.props.setMessage({ 209 | message: "Records successfully updated!", 210 | messageClass: "alert alert-success" 211 | }); 212 | } else if (response.status === 201) { 213 | this.props.setMessage({ 214 | message: "Records successfully added!", 215 | messageClass: "alert alert-success" 216 | }); 217 | } else if (response.status === 204) { 218 | this.props.setMessage({ 219 | message: "Record deleted!", 220 | messageClass: "alert alert-success" 221 | }); 222 | } 223 | } 224 | // update the main table with the new values 225 | this.dataUpdate({ updated: response.data, commit: true }); 226 | }) 227 | .catch(error => { 228 | console.log(error); 229 | this.props.setMessage({ 230 | message: "An API error has occurred", 231 | messageClass: "alert alert-danger" 232 | }); 233 | this.handleCloseModal({ actionCancelled: !reqFieldsComplete }); // close modal 234 | }); 235 | } 236 | } else { 237 | console.log( 238 | "Record update was not committed and no API call was made!" 239 | ); 240 | } 241 | } 242 | } 243 | 244 | deletion() { 245 | return ( 246 | 255 | ); 256 | } 257 | 258 | tables() { 259 | if (this.props.accountMode === this.props.accountModes.WAREHOUSE) { 260 | return ( 261 |
    262 | 273 |
    274 | ); 275 | } else if (this.props.accountMode === this.props.accountModes.STORE) { 276 | return ( 277 |
    278 | 289 |
    290 | ); 291 | } 292 | } 293 | 294 | render() { 295 | return ( 296 | 304 |
    305 |
    306 |
    307 |

    (this.subtitle = subtitle)}>Manage Stock

    308 |
    309 |
    310 | 318 |
    319 |
    320 |
    321 | {this.state.deleteRecord ? this.deletion() : this.tables()} 322 |
    323 |
    324 |
    325 | ); 326 | } 327 | } 328 | 329 | export default StockUpdateModal; 330 | -------------------------------------------------------------------------------- /assets/js/index.jsx: -------------------------------------------------------------------------------- 1 | /* react */ 2 | import React, {Component} from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | /* environment settings */ 5 | import Env from './env.jsx'; 6 | /* app components */ 7 | import LoginForm from './loginform.jsx'; 8 | import DataTable from './data-table.jsx'; 9 | import StockUpdateModal from './stock-update-modal.jsx'; 10 | import ApiRequest from './api-request.jsx'; 11 | import Message from './message.jsx'; 12 | import Footer from './footer.jsx'; 13 | /* cookies */ 14 | import Cookies from 'js-cookie'; 15 | 16 | /* css */ 17 | import '../css/index.css'; 18 | /* font awesome icons */ 19 | import {library} from '@fortawesome/fontawesome-svg-core' 20 | import {faSyncAlt, faEllipsisH, faPlus, faTrashAlt, faEdit} from '@fortawesome/free-solid-svg-icons' 21 | 22 | library.add(faSyncAlt, faEllipsisH, faPlus, faTrashAlt, faEdit); 23 | /* axios */ 24 | import axios from 'axios/index'; 25 | 26 | axios.defaults.withCredentials = true; 27 | 28 | /* App root */ 29 | class App extends React.Component { 30 | 31 | static APP_DETAILS = Env().appDetails; 32 | 33 | static API_OPTIONS = 34 | { 35 | /* used to define available API options in the api-request component */ 36 | GET_STOCK: {method: 'GET', desc: 'request to get stock data'}, 37 | PATCH_STOCK: {method: 'PATCH', desc: 'PATCH request to update stock data'}, 38 | ADD_STOCK: {method: 'POST', desc: 'POST request to add stock data'}, 39 | DELETE_STOCK_LINE: {method: 'DELETE', desc: 'DELETE request to delete stock line'}, 40 | POST_AUTH: {method: 'POST', desc: 'POST request to for authorization'}, 41 | PATCH_CHANGE_PW: {method: 'PATCH', desc: 'PATCH request to for changing password'}, 42 | }; 43 | 44 | static defaultStockRecordData = () => { 45 | return {data: {results: []}} 46 | }; 47 | 48 | static defaultStockRecordMeta = () => { 49 | return { 50 | page: 1, 51 | limit: 25, 52 | pagerMainSize: 5, 53 | pagerEndSize: 3, 54 | orderBy: 'sku', 55 | orderDir: '-', 56 | preserveOrder: false, 57 | cacheControl: 'no-cache', // no caching by default, so always returns fresh data 58 | url: null, 59 | userIsAdmin: false, 60 | search: '' 61 | } 62 | }; 63 | 64 | static defaultStockUpdateData = () => { 65 | return { 66 | units_total: 0, 67 | unit_price: 0.00, 68 | desc: '', 69 | sku: '' 70 | } 71 | }; 72 | 73 | static defaultStockUpdateMeta = () => { 74 | return { 75 | cacheControl: 'no-cache', // no caching by default, so always returns fresh data 76 | url: null, 77 | userIsAdmin: false, 78 | newRecord: false, 79 | deleteRecord: false, 80 | } 81 | }; 82 | 83 | static defaultAuthMeta = () => { 84 | return { 85 | url: `${Env().apiRoot}/api-token-auth/`, 86 | change_pw_url: `${Env().apiRoot}/v1/change-password/`, 87 | authenticated: Boolean(sessionStorage.getItem('apiToken')), 88 | cacheControl: 'no-cache', 89 | requestData: null, 90 | } 91 | }; 92 | 93 | 94 | // constructor fires before component mounted 95 | constructor(props) { 96 | super(props); // makes 'this' refer to component (i.e. like python self) 97 | // set local state 98 | this.state = { 99 | // only initialise stockData with required defaults for 1st request and 100 | // BEFORE data returned. Rest all added in call to stockDataHandler called after response received 101 | stockRecordData: App.defaultStockRecordData(), 102 | stockRecordMeta: App.defaultStockRecordMeta(), 103 | stockUpdateData: App.defaultStockUpdateData(), 104 | stockUpdateMeta: App.defaultStockUpdateMeta(), 105 | authMeta: App.defaultAuthMeta(), 106 | stockUpdateModalOpen: false, 107 | apiTrigger: null, // will be one of API_OPTIONS when triggered 108 | message: null, 109 | messageClass: '', 110 | greeting: App.APP_DETAILS.greeting, 111 | csrfToken: this.getCSRFToken() 112 | }; 113 | } 114 | 115 | getCSRFToken = () => { 116 | return Cookies.get('csrftoken') 117 | }; 118 | 119 | setSessionStorage = ({key, value}) => { 120 | sessionStorage.setItem(key, value); 121 | }; 122 | 123 | getSessionStorage = (key) => { 124 | //return JSON.parse(localStorage.getItem(key)); 125 | return sessionStorage.getItem(key); 126 | }; 127 | 128 | deleteSessionStorage = (keys = []) => { 129 | if (keys.length > 0) { 130 | keys.forEach((k) => { 131 | sessionStorage.removeItem(k) 132 | }) 133 | } 134 | return true; 135 | }; 136 | 137 | 138 | authenticate = ({apiTrigger = null, requestData = null, auth = false} = {}) => { 139 | // ensure token deleted if auth false 140 | !auth ? this.deleteSessionStorage(['apiToken', 'username']) : null; 141 | // triggers API to get auth token 142 | this.setState({ 143 | apiTrigger: apiTrigger, 144 | authMeta: { 145 | url: this.state.authMeta.url, 146 | change_pw_url: this.state.authMeta.change_pw_url, 147 | authenticated: auth, 148 | requestData: requestData 149 | } 150 | }) 151 | }; 152 | 153 | resetUpdateDataState = () => { 154 | this.setState({ 155 | stockUpdateData: App.defaultStockUpdateData(), 156 | stockUpdateMeta: App.defaultStockUpdateMeta(), 157 | }) 158 | }; 159 | 160 | setStockRecordState = ({stockRecord, updatedData, apiTrigger = null} = {}) => { 161 | /* 162 | method to update state for record being retrieved (GET request) 163 | */ 164 | let metaState = this.state.stockRecordMeta; 165 | let recordState = stockRecord && stockRecord.data ? stockRecord.data : this.state.stockRecordData; 166 | //let metaState = this.state.stockRecordMeta; 167 | // record response data 168 | if (updatedData) { // update just fields of the record being updated 169 | Object.entries(recordState.data.results).forEach((r, index) => { 170 | if (r[1]['id'] === updatedData['id']) { 171 | recordState['data']['results'][index] = updatedData 172 | } 173 | }); 174 | metaState['datetime_of_request'] = updatedData['datetime_of_request']; 175 | metaState['userIsAdmin'] = updatedData['user_is_admin']; 176 | this.setMessage({message: 'Record updated!', messageClass: 'alert alert-success'}) 177 | } 178 | if (stockRecord) { 179 | /*update record request meta (sets new request URLs, etc) */ 180 | Object.entries(stockRecord.meta ? stockRecord.meta : metaState).forEach((kvArray) => { 181 | metaState[kvArray[0]] = kvArray[1]; 182 | }); 183 | if (recordState['data']['results'].length) { 184 | // add time of request from recordState to metaState 185 | metaState['datetime_of_request'] = recordState['data']['results'][0]['datetime_of_request']; 186 | // add user admin status from recordState to metaState 187 | metaState['userIsAdmin'] = recordState['data']['results'][0]['user_is_admin']; 188 | } 189 | } 190 | // ensure page never falls below 1! 191 | metaState.page < 1 ? metaState.page = 1 : metaState.page; 192 | // set new state, then once done (as callback) reset state data to defaults 193 | this.setState({ 194 | stockRecordData: recordState, stockRecordMeta: metaState, 195 | apiTrigger: apiTrigger, 196 | }, this.resetUpdateDataState()); 197 | }; 198 | 199 | setStockUpdateRecordState = ({record = null, apiTrigger = null, clearData = false} = {}) => { 200 | /* 201 | method to update the state for the record being edited (PATCH request), or add 202 | new record 203 | */ 204 | clearData ? this.setState({stockUpdateData: App.defaultStockUpdateData()}) : null; // clear updateData if requested 205 | let recordState = this.state.stockUpdateData; 206 | let metaState = this.state.stockUpdateMeta; 207 | let admin = false; 208 | Object.entries(record && record.data ? record.data : recordState).forEach((kvArray) => { 209 | /*add data to record (removing unnecessary elements & assigning 210 | user admin status to stockUpdateMeta instead */ 211 | kvArray[0] === 'user_is_admin' ? admin = kvArray[1] : null; 212 | recordState[kvArray[0]] = kvArray[1]; 213 | }); 214 | Object.entries(record && record.meta ? record.meta : metaState).forEach((kvArray) => { 215 | // add new meta to record 216 | metaState[kvArray[0]] = kvArray[1]; 217 | }); 218 | metaState['userIsAdmin'] = admin; 219 | this.setState({ 220 | stockUpdateMeta: metaState, 221 | stockUpdateData: recordState, 222 | apiTrigger: apiTrigger 223 | }); 224 | }; 225 | 226 | 227 | openStockUpdateModalHandler = ({record = this.state.stockUpdateData} = {}) => { 228 | /* 229 | method to open the stock addition/update modal & send incoming record-to-be-updated 230 | to the setStockUpdateRecordState method (to update the state!) 231 | */ 232 | this.setStockUpdateRecordState({record: record}); 233 | this.setStockUpdateModalState({state: true}); 234 | }; 235 | 236 | setStockUpdateModalState = ({state = false, actionCancelled = false} = {}) => { 237 | /* 238 | method to update the state for the modal open/close state and 239 | if action was cancelled, RESET stockUpdateData & stockUpdateMeta to default values 240 | */ 241 | this.setState({stockUpdateModalOpen: state}); 242 | actionCancelled ? this.resetUpdateDataState() : null; 243 | }; 244 | 245 | disarmAPI = () => { 246 | // disables API active state 247 | this.setState({apiTrigger: null}) 248 | }; 249 | 250 | setMessage = ({message = null, messageClass = ''} = {}) => { 251 | this.setState({message: message, messageClass: messageClass}); 252 | }; 253 | 254 | render() { 255 | let dataTable; 256 | if (this.state.authMeta.authenticated) { 257 | dataTable = ( 258 | 268 | ) 269 | } 270 | return ( 271 |
    272 |
    273 |
    274 |
    275 | 283 | 286 | {dataTable} 287 |
    291 | 300 | 316 |
    317 |
    318 |
    319 |
    320 | ); 321 | }; 322 | } 323 | 324 | ReactDOM.render( 325 | , document.getElementById('react-app')); -------------------------------------------------------------------------------- /assets/js/stock-update-modal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import '../css/stock-update-modal.css'; 3 | import Modal from "react-modal"; 4 | import Env from './env.jsx'; 5 | 6 | Modal.setAppElement('#react-app'); 7 | 8 | const REGULAR_STYLES = { 9 | top: '50%', 10 | left: '50%', 11 | right: 'auto', 12 | bottom: 'auto', 13 | marginRight: '-50%', 14 | transform: 'translate(-50%, -50%)', 15 | backgroundColor: '#001e00', 16 | color: 'yellow', 17 | border: '1px solid yellow', 18 | borderRadius: '7px 7px 7px 7px', 19 | boxShadow: '-7px -7px 17px 7px #001e00', 20 | maxWidth: '800px' 21 | }; 22 | 23 | const DANGER_STYLES = { 24 | top: '50%', 25 | left: '50%', 26 | right: 'auto', 27 | bottom: 'auto', 28 | marginRight: '-50%', 29 | transform: 'translate(-50%, -50%)', 30 | backgroundColor: 'darkred', 31 | color: 'white', 32 | border: '1px solid yellow', 33 | borderRadius: '7px 7px 7px 7px', 34 | boxShadow: '-7px -7px 17px 7px #001e00', 35 | maxWidth: '800px' 36 | }; 37 | 38 | class StockUpdateModal extends React.Component { 39 | 40 | constructor(props) { 41 | super(props); 42 | // Remember! This binding is necessary to make `this` work in the callback 43 | // keep real-time props changes in component state 44 | this.state = { 45 | stockUpdateMeta: this.props.stockUpdateMeta, 46 | modalIsOpen: false, 47 | id: null, 48 | units_total: 0, 49 | unit_price: 0.00, 50 | desc: '', 51 | sku: '', 52 | unitsToTransfer: 0, 53 | newRecord: false, 54 | deleteRecord: false, 55 | modalStyles: {content: REGULAR_STYLES} 56 | }; 57 | this.handleOpenModal = this.handleOpenModal.bind(this); 58 | this.handleAfterOpenModal = this.handleAfterOpenModal.bind(this); 59 | this.handleCloseModal = this.handleCloseModal.bind(this); 60 | } 61 | 62 | componentWillMount() { 63 | } 64 | 65 | componentWillUnmount() { 66 | } 67 | 68 | 69 | componentWillReceiveProps(nextProps) { 70 | this.setState({ 71 | stockUpdateMeta: nextProps.stockUpdateMeta, 72 | id: nextProps.stockUpdateData.id, 73 | units_total: nextProps.stockUpdateData.units_total, 74 | unit_price: nextProps.stockUpdateData.unit_price, 75 | sku: nextProps.stockUpdateData.sku, 76 | desc: nextProps.stockUpdateData.desc, 77 | newRecord: nextProps.stockUpdateMeta.newRecord, 78 | deleteRecord: nextProps.stockUpdateMeta.deleteRecord 79 | }); 80 | nextProps.stockUpdateMeta.deleteRecord ? this.state.modalStyles.content = DANGER_STYLES : 81 | this.state.modalStyles.content = REGULAR_STYLES; 82 | // if signal to open modal, open it 83 | if (nextProps.openStockUpdateModal) { 84 | this.handleOpenModal(); 85 | } 86 | } 87 | 88 | handleOpenModal() { 89 | this.setState({modalIsOpen: true}); 90 | } 91 | 92 | handleAfterOpenModal() { 93 | // this.subtitle.style.color = 'yellow'; 94 | } 95 | 96 | handleCloseModal({actionCancelled = false} = {}) { 97 | // first clear temp values from component state 98 | this.setState({ 99 | id: null, 100 | units_total: 0, 101 | unit_price: 0.00, 102 | sku: '', 103 | desc: '', 104 | unitsToTransfer: 0, 105 | stockUpdateMeta: null, 106 | newRecord: false, 107 | deleteRecord: false, 108 | modalIsOpen: false // close modal, 109 | }); 110 | // set modal state in index.jsx back to closed 111 | this.props.setStockUpdateModalState({state: false, actionCancelled: actionCancelled}); 112 | } 113 | 114 | handleRecordUpdate() { 115 | /* 116 | method to update record data (cancel if desc or sku fields were empty) 117 | */ 118 | let allFieldsComplete = !!(this.state.desc && this.state.sku); 119 | if (allFieldsComplete) { 120 | this.props.setStockUpdateRecordState({ 121 | record: { 122 | data: { 123 | units_total: this.state.units_total, 124 | unit_price: this.state.unit_price, 125 | sku: this.state.sku, 126 | desc: this.state.desc 127 | }, 128 | meta: { 129 | url: `${Env().apiDataRoot}/stock/` 130 | } 131 | }, 132 | apiTrigger: this.state.newRecord ? 133 | this.props.API_OPTIONS.ADD_STOCK : this.props.API_OPTIONS.PATCH_STOCK 134 | }); 135 | } 136 | this.handleCloseModal({actionCancelled: !allFieldsComplete }); 137 | } 138 | 139 | handleRecordDelete() { 140 | /* 141 | method to handle deletion of a stock line (record) 142 | */ 143 | this.props.setStockUpdateRecordState({ 144 | record: { 145 | meta: { 146 | url: `${Env().apiDataRoot}/stock/${this.state.id}` 147 | } 148 | }, 149 | apiTrigger: this.props.API_OPTIONS.DELETE_STOCK_LINE 150 | }); 151 | this.handleCloseModal(); 152 | } 153 | 154 | 155 | validatePrice(value) { 156 | return (/^[\d]*[\.]?[\d]{0,2}$/.test(value)) ? value : this.state.unit_price 157 | } 158 | 159 | validateDesc(value) { 160 | return (/^[a-zA-Z\d.\- ]*$/.test(value)) ? value : this.state.desc 161 | } 162 | 163 | generateEditForm() { 164 | if (this.state.stockUpdateMeta.deleteRecord) { 165 | return ( 166 |
    167 |
    168 |

    Delete "{this.state.desc}"

    169 |
    170 | 172 |
    173 | 179 | 185 |
    186 |
    187 |
    188 |
    189 | ) 190 | } else if (this.state.stockUpdateMeta.userIsAdmin || this.state.stockUpdateMeta.newRecord) { 191 | // note: if newRecord, no userIsAdmin state has been set, hence need to test for newRecord separately 192 | return ( 193 |
    194 |
    195 |
    196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 213 | 214 | 215 | 216 | 222 | 223 | 224 | 225 | 233 | 234 | 235 | 236 | 246 | 247 | 248 |
    Current Record
    Stock AttributeValue
    208 | this.setState({sku: e.target.value})} 211 | className={'form-control'} type={'text'}/> 212 |
    217 | this.setState({desc: this.validateDesc(e.target.value)})} 220 | className={'form-control'} type={'text'}/> 221 |
    226 | this.setState({ 229 | units_total: e.target.value ? parseInt(e.target.value) : 0 230 | })} 231 | className={'form-control'} type={'number'}/> 232 |
    237 | this.setState({ 240 | unit_price: 241 | this.validatePrice(e.target.value) 242 | })} 243 | className={'form-control'} type={'text'} 244 | /> 245 |
    249 |
    250 |
    251 |
    252 |
    253 |
    254 | 264 |
    265 |
    266 |
    267 |
    268 | ) 269 | } 270 | else { 271 | return ( 272 |
    273 |
    274 |
    275 | 284 |
    285 | 296 |
    297 |
    298 |
    299 |
    300 | ) 301 | } 302 | } 303 | 304 | render() { 305 | if (this.state.stockUpdateMeta) { 306 | return ( 307 | 314 |
    315 |
    316 |
    317 |

    this.subtitle = subtitle}>Manage Stock

    318 |
    319 |
    320 | 325 |
    326 |
    327 |
    328 |
    329 | {this.generateEditForm()} 330 |
    331 |
    332 |
    333 |
    334 | ); 335 | } 336 | else { 337 | return ( 338 |
    339 | ) 340 | } 341 | } 342 | } 343 | 344 | export default StockUpdateModal; -------------------------------------------------------------------------------- /src/api-request.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import axios from 'axios/index'; 3 | 4 | class ApiRequest extends React.Component { 5 | 6 | constructor(props) { 7 | super(props); 8 | // Remember! This binding is necessary to make `this` work in the callback 9 | // keep real-time props changes in component state 10 | this.setFormApi = this.setFormApi.bind(this); 11 | this.state = { 12 | activeRequest: false, 13 | currentOrderBy: '', 14 | currentOrderDir: '', 15 | currentPage: 1, 16 | }; 17 | } 18 | 19 | componentWillMount() { 20 | } 21 | 22 | componentWillUnmount() { 23 | } 24 | 25 | componentWillReceiveProps(nextProps) { 26 | let orderDir, url; 27 | let getStockMeta = nextProps.stockRecordMeta; 28 | let updateStockMeta = nextProps.stockUpdateMeta; 29 | let updateStockData = nextProps.stockUpdateData; 30 | let authMeta = nextProps.authMeta; 31 | let csrfToken = nextProps.csrfToken; 32 | switch (nextProps.apiTrigger) { 33 | case this.props.apiOptions.GET_STOCK: // get stock data 34 | orderDir = this.generateRecordRequestOrder(getStockMeta); 35 | url = getStockMeta.url ? getStockMeta.url : 36 | `${process.env.REACT_APP_API_DATA_ROUTE}/stock/?limit=${getStockMeta.limit}` + 37 | `&offset=${(getStockMeta.page * getStockMeta.limit) - getStockMeta.limit}` + 38 | `&order_by=${orderDir}${getStockMeta.orderBy}&desc=${getStockMeta.search}`; 39 | this.props.setStockRecordState({ 40 | stockRecord: {meta: {orderDir: orderDir, url: null}} 41 | });// set new order dir state 42 | this.sendRequest({ 43 | url: url, 44 | csrfToken: csrfToken, 45 | requestType: this.props.apiOptions.GET_STOCK, 46 | requestMeta: getStockMeta 47 | }); 48 | break; 49 | case this.props.apiOptions.PATCH_STOCK: // update stock data 50 | url = updateStockData ? 51 | `${process.env.REACT_APP_API_DATA_ROUTE}/stock/${updateStockData.id}/` : 52 | updateStockMeta.url; 53 | this.sendRequest({ 54 | url: url, 55 | csrfToken: csrfToken, 56 | requestData: updateStockData, 57 | requestType: this.props.apiOptions.PATCH_STOCK, 58 | requestMeta: updateStockMeta 59 | }); 60 | break; 61 | case this.props.apiOptions.ADD_STOCK: // update stock data 62 | url = updateStockMeta.url; 63 | this.sendRequest({ 64 | url: url, 65 | csrfToken: csrfToken, 66 | requestData: updateStockData, 67 | requestType: this.props.apiOptions.ADD_STOCK, 68 | requestMeta: updateStockMeta 69 | }); 70 | break; 71 | case this.props.apiOptions.DELETE_STOCK_LINE: // delete stock line 72 | url = updateStockData ? `${process.env.REACT_APP_API_DATA_ROUTE}/stock/${updateStockData.id}/` : 73 | null; 74 | this.sendRequest({ 75 | url: url, 76 | csrfToken: csrfToken, 77 | requestData: updateStockData, 78 | requestType: this.props.apiOptions.DELETE_STOCK_LINE, 79 | requestMeta: updateStockMeta 80 | }); 81 | break; 82 | case this.props.apiOptions.POST_AUTH: // get auth token 83 | url = authMeta.url; 84 | this.sendRequest({ 85 | url: url, 86 | csrfToken: csrfToken, 87 | requestType: this.props.apiOptions.POST_AUTH, 88 | requestData: { 89 | username: authMeta.requestData.data.username, 90 | password: authMeta.requestData.data.password 91 | } 92 | }); 93 | break; 94 | case this.props.apiOptions.PATCH_CHANGE_PW: // change password 95 | url = `${authMeta.change_pw_url}${authMeta.requestData.data.username}/`; 96 | this.sendRequest({ 97 | url: url, 98 | csrfToken: csrfToken, 99 | requestType: this.props.apiOptions.PATCH_CHANGE_PW, 100 | requestData: { 101 | old_password: authMeta.requestData.data.old_password, 102 | new_password: authMeta.requestData.data.new_password 103 | } 104 | }); 105 | break; 106 | default: 107 | // console.log('Not calling API, nothing to do ...') 108 | } 109 | } 110 | 111 | setFormApi(formApi) { 112 | this.formApi = formApi; 113 | } 114 | 115 | generateRecordRequestOrder(requestMeta) { 116 | /* 117 | Method to generate the request ordering. 118 | Reverses ordering if column clicked when 119 | already ordered on the same column. 120 | Otherwise, defaults to ascending order. 121 | 122 | Note: page always reverts to page 1 IF column clicked to change order on any 123 | page other than 1, as new ordering is requested from API. Needs to return to page 1 to 124 | display the newly requested (differently sorted) pages - 125 | otherwise the current page would display any data that happened to correspond to that 126 | page of newly received data, and that wouldn't be expected behaviour. 127 | Could also add method (& UI link/button/widget) for columns to LOCALLY order/sort presently 128 | displayed page data (without an API request) if required. 129 | */ 130 | if (requestMeta.preserveOrder) return requestMeta.orderDir; // end here if current order to be preserved 131 | if (this.state.currentOrderBy !== requestMeta.orderBy) { 132 | this.setState({ 133 | currentOrderDir: '', 134 | currentOrderBy: requestMeta.orderBy, 135 | }); 136 | return '' 137 | } 138 | if (this.state.currentPage === requestMeta.page) { 139 | let newOrderDir = this.state.currentOrderDir === '-' ? '' : "-"; 140 | this.setState({ 141 | currentOrderDir: newOrderDir 142 | }); 143 | return newOrderDir; 144 | } else { 145 | this.setState({ 146 | currentPage: requestMeta.page 147 | }); 148 | return this.state.currentOrderDir 149 | } 150 | } 151 | 152 | sendRequest({url = null, requestData = null, requestMeta = null, requestType = null, csrfToken = null} = {}) { 153 | this.props.disarmAPI(); // disarm the API now the command has been actioned 154 | if (!this.state.activeRequest && url && requestType) { 155 | this.setState({ 156 | activeRequest: true, 157 | }); 158 | // make request 159 | //console.log('Sending request to API'); 160 | axios({ 161 | method: requestType.method, 162 | url: url, 163 | responseType: 'json', 164 | data: requestData ? requestData : {}, 165 | //auth: {}, 166 | headers: { 167 | Authorization: this.props.getSessionStorage('apiToken') ? 168 | `Token ${this.props.getSessionStorage('apiToken')}` : null, 169 | // 'cache-control': requestMeta ? requestMeta.cacheControl : 'no-cache', 170 | 'Content-Type': 'application/json', 171 | 'X-CSRFToken': csrfToken, 172 | } // additional headers here 173 | }).then((response) => { 174 | this.setState({activeRequest: false}); 175 | this.handleAPIResponse({ 176 | requestData: requestData, requestType: requestType, 177 | response: response 178 | }); 179 | }).catch(error => { 180 | this.setState({activeRequest: false}); 181 | this.handleAPIResponse({error: error, requestType: requestType}); 182 | }); 183 | } else { 184 | //console.log('Request already active or no URL set!') 185 | } 186 | } 187 | 188 | handleAPIResponse({requestData = null, requestType = null, response = null, error = null} = {}) { 189 | if (error) { 190 | switch (requestType) { 191 | case this.props.apiOptions.GET_STOCK: 192 | this.props.setMessage({ 193 | message: error.response ? String(error.response.data[0]) : String(error), 194 | messageClass: 'alert alert-danger' 195 | }); 196 | break; 197 | case this.props.apiOptions.PATCH_STOCK: 198 | case this.props.apiOptions.ADD_STOCK: 199 | case this.props.apiOptions.DELETE_STOCK_LINE: 200 | let msgArray = []; 201 | this.props.setStockUpdateRecordState({clearData: true}); // clear data for next time 202 | if (error.response && error.response.data) { 203 | Object.keys(error.response.data).forEach((key) => msgArray.push(error.response.data[key])); 204 | this.props.setMessage({ 205 | message: msgArray.length >= 0 ? msgArray.join(', ') : error, 206 | messageClass: 'alert alert-danger' 207 | }); 208 | } else { 209 | this.props.setMessage({ 210 | message: 'An api error occurred!', 211 | messageClass: 'alert alert-danger' 212 | }); 213 | } 214 | break; 215 | case this.props.apiOptions.POST_AUTH: 216 | case this.props.apiOptions.PATCH_CHANGE_PW: 217 | let message = 'An error occurred! '; 218 | if (error.response) { 219 | if (error.response.data) { 220 | if (error.response.data.old_password) { 221 | message += `Old password error: ${error.response.data.old_password} `; 222 | } 223 | if (error.response.data.new_password) { 224 | message += `New password error: ${error.response.data.new_password} `; 225 | } 226 | } else if (error.response.detail) { 227 | message += `Error detail: ${error.response.detail} `; 228 | } 229 | } 230 | else { 231 | message += error 232 | } 233 | this.props.setMessage({ 234 | message: message, 235 | messageClass: 'alert alert-danger' 236 | }); 237 | this.props.authenticate({auth: false}); // ensure unauthenticated status is set 238 | break; 239 | default: 240 | this.props.setMessage({ 241 | message: 'An api error occurred!', 242 | messageClass: 'alert alert-danger' 243 | }); 244 | } 245 | } else { 246 | switch (requestType) { 247 | case this.props.apiOptions.GET_STOCK: 248 | this.props.setStockRecordState({ 249 | stockRecord: {data: response, meta: {preserveOrder: false}} 250 | }); 251 | break; 252 | case this.props.apiOptions.PATCH_STOCK: 253 | this.props.setStockRecordState({ 254 | updatedData: response.data 255 | }); 256 | this.props.setStockUpdateRecordState({clearData: true}); // clear data for next time 257 | this.props.setMessage({ 258 | message: 'Stock action successful!', 259 | messageClass: 'alert alert-success' 260 | }); 261 | break; 262 | case this.props.apiOptions.ADD_STOCK: 263 | case this.props.apiOptions.DELETE_STOCK_LINE: 264 | this.props.setStockUpdateRecordState({clearData: true}); // clear data for next time 265 | this.props.setStockRecordState({ 266 | stockRecord: {meta: {preserveOrder: true}}, 267 | apiTrigger: this.props.apiOptions.GET_STOCK 268 | }); // sets data to be refreshed & rebuild of table 269 | this.props.setMessage({ 270 | message: 'Stock action successful!', 271 | messageClass: 'alert alert-success' 272 | }); 273 | break; 274 | case this.props.apiOptions.POST_AUTH: 275 | if (response.data) { 276 | let token = response.data.token; 277 | this.props.setSessionStorage({key: 'apiToken', value: token}); // stick token into local browser storage 278 | this.props.setSessionStorage({key: 'username', value: requestData.username}); // store username for good measure 279 | this.props.authenticate({auth: true}); // set authenticated status 280 | this.props.setMessage({ 281 | message: 'Authentication successful', 282 | messageClass: 'alert alert-success' 283 | }); 284 | } 285 | else { 286 | this.props.setMessage({ 287 | message: 'An error occurred whilst connecting to the API.', 288 | messageClass: 'alert alert-danger' 289 | }) 290 | } 291 | break; 292 | case this.props.apiOptions.PATCH_CHANGE_PW: 293 | if (response.data) { 294 | this.props.authenticate({auth: false}); // log out 295 | this.props.setMessage({ 296 | message: 'Password changed! Please login using your new credentials.', 297 | messageClass: 'alert alert-success' 298 | }) 299 | } else { 300 | this.props.setMessage({ 301 | message: 'An error occurred whilst connecting to the API.', 302 | messageClass: 'alert alert-danger' 303 | }) 304 | } 305 | break; 306 | default: 307 | this.props.setMessage({ 308 | message: 'API action has completed ...', 309 | messageClass: 'alert alert-success' 310 | }) 311 | } 312 | } 313 | } 314 | 315 | /* 316 | render stuff ... 317 | */ 318 | 319 | renderDefault() { 320 | return ( 321 |
    322 | ) 323 | } 324 | 325 | render() { 326 | return ( 327 |
    {this.renderDefault()}
    328 | ); 329 | } 330 | } 331 | 332 | export default ApiRequest; --------------------------------------------------------------------------------